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/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/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..fc87ad4fa2 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,32 @@ 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))) +} + +/// `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 +476,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 +550,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 +587,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 +596,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")); } @@ -608,8 +682,8 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo return Ok(b); } let ItemTarget::Item { index, .. } = item else { return Ok(default) }; - let ids = appearance_targets(s, p)?; let d = &s.doc()?.doc; + let ids = drop_design_art(d, appearance_targets(s, p)?); Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } @@ -1032,7 +1106,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 +1114,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 +1202,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 +1244,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 +1363,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/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..118773cfc8 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -13,7 +13,9 @@ 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, 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 +51,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 +69,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 +78,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 +87,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 +105,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 +114,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 +298,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 +320,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 +332,19 @@ 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; + +/// The [`design_reference`] of an effect edit aimed at `item`: none for the Appearance panel's active row, which is a +/// row of the first selected object's stack. +fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result> { + if matches!(item, ItemTarget::Item { explicit: false, .. }) { + return Ok(None); + } + design_reference(s, p, cmd) +} + #[allow(clippy::too_many_arguments)] fn edit_effects( s: &mut Session, @@ -362,6 +387,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 +492,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 +518,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 +544,7 @@ fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result= 2 && !grouped).then_some(roots)) } @@ -608,9 +657,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 +947,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 +1277,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 +1302,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 +1317,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..ce919c7cc9 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; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index a46f46492a..1710f57eb9 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 ), diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index c823b8866f..c5b8858f98 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::Value; -use vectorcraft_doc::{Artboard, Document, Node, NodeKind, SymbolOverride}; +use vectorcraft_doc::{Artboard, Document, Node, NodeKind, SYMBOL_SIZES, SymbolOverride}; use vectorcraft_geom::Rect; use vectorcraft_render::AntiAlias; use vectorcraft_render::encode::jpeg::{self, JpegOptions}; @@ -454,7 +454,12 @@ pub fn export_art_bounds(doc: &Document, format: &str) -> Option { /// make the same appearance changes to its art are measured on a copy of the symbol of their own, /// in which each object is judged with those changes ([`as_drawn`]); the objects taken out of /// a dynamic symbol's art leave empty groups in their places, by which instances find the objects -/// they change. +/// they change. Each copy keeps its symbol's 9-slice guides and natural size. An instance whose art +/// goes through 9-slice guides at any depth is measured as its art placed through them +/// ([`placed_instances`]), with the objects `left_out` picks hidden after placing: the guides cut +/// the box of the whole art. Where an instance stays one, the art of a symbol with 9-slice guides, +/// and of the symbols whose instances are in that art at any depth ([`sliced_art`]), is measured +/// whole. fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option { // The symbols instances show (the first of each name). let mut names = HashSet::new(); @@ -492,12 +497,15 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option)> = shown @@ -517,11 +525,25 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option)> = entries .iter() .filter_map(|(i, c)| { - let s = src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()))?; + let s = src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()) && !whole.contains(s.name.as_str()))?; without_nodes(&s.art, left_out, *c, &mut Vec::new()).map(|art| (*i, art)) }) .collect(); @@ -540,6 +562,135 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option HashSet<&str> { + let mut arts: HashMap<&str, &Arc> = HashMap::new(); + for s in &doc.symbols { + arts.entry(s.name.as_str()).or_insert(&s.art); + } + let mut todo: Vec<&str> = arts.keys().copied().filter(|name| doc.symbol_nine_slice(name).is_some()).collect(); + let mut found = HashSet::new(); + while let Some(name) = todo.pop() { + if !found.insert(name) { + continue; + } + if let Some(&art) = arts.get(name) { + art.walk(&mut |n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { + todo.push(symbol.as_str()); + } + }); + } + } + found +} + +/// The names of the symbols of `doc` in `sliced`, and of the symbols whose art holds an instance of +/// one of those at any depth. +fn holding<'a>(doc: &'a Document, sliced: &HashSet<&'a str>) -> HashSet<&'a str> { + let mut held_by: HashMap<&str, Vec<&str>> = HashMap::new(); + let mut seen = HashSet::new(); + for s in doc.symbols.iter().filter(|s| seen.insert(s.name.as_str())) { + s.art.walk(&mut |n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { + held_by.entry(symbol.as_str()).or_default().push(s.name.as_str()); + } + }); + } + let mut found = HashSet::new(); + let mut todo: Vec<&str> = sliced.iter().copied().collect(); + while let Some(name) = todo.pop() { + if found.insert(name) { + todo.extend(held_by.get(name).into_iter().flatten().copied()); + } + } + found +} + +/// The most instances deep [`placed_instances`] places symbol art. +const MAX_PLACED_DEPTH: usize = 32; + +/// `n` with each instance of the `placing` symbols in it, and in its layers and groups at any depth, +/// made a group with the instance's appearance that holds the instance's art (with its changes and +/// stain) as `doc` places it, through the symbol's 9-slice guides when it has them, without the +/// objects `picked` picks ([`without_nodes`]; hidden when it picks the whole art); `None` when `n` +/// holds no such instance, or is hidden. An instance on a perspective grid, one +/// [`MAX_PLACED_DEPTH`] instances deep, one in another kind of object (a blend, for example), and +/// one whose art would take the objects placed in all (`budget`) past +/// [`vectorcraft_doc::MAX_SYMBOL_NODES`] stay instances. +/// Their symbols' art is measured whole, as taking objects out of it would change the box the +/// guides cut, and the page can come out larger than the plot. +fn placed_instances( + doc: &Document, + n: &Arc, + placing: &HashSet<&str>, + picked: &dyn Fn(&Node) -> bool, + budget: &mut u64, + depth: usize, +) -> Option> { + // Hidden art is not plotted, and placing it would take from the budget of the art that is. + if !n.visible { + return None; + } + match &n.kind { + NodeKind::SymbolInstance { symbol, xf } => { + if !placing.contains(symbol.as_str()) || depth >= MAX_PLACED_DEPTH || n.projection().is_some() { + return None; + } + let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n); + let most = doc.symbol_slice_map(symbol, *xf).map_or(art.count_with_masks() as u64, |m| m.most_placed(&art, doc)); + if budget.saturating_add(most) > vectorcraft_doc::MAX_SYMBOL_NODES { + return None; + } + let art = Arc::new(doc.place_symbol_art(symbol, &art, *xf)?); + *budget = budget.saturating_add(art.count_with_masks() as u64); + let art = placed_instances(doc, &art, placing, picked, budget, depth + 1).unwrap_or(art); + let art = if picked(&art) { + let mut hidden = (*art).clone(); + hidden.visible = false; + Arc::new(hidden) + } else { + without_nodes(&art, picked, None, &mut Vec::new()).unwrap_or(art) + }; + let mut g = (**n).clone(); + g.kind = NodeKind::Group { children: vec![art], clip: false }; + Some(Arc::new(g)) + } + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + let mut out: Option>> = None; + for (i, c) in children.iter().enumerate() { + if let Some(new) = placed_instances(doc, c, placing, picked, budget, depth) + && let Some(slot) = out.get_or_insert_with(|| children.clone()).get_mut(i) + { + *slot = new; + } + } + out.map(|c| { + let mut m = (**n).clone(); + if let Some(children) = m.children_mut() { + *children = c; + } + Arc::new(m) + }) + } + _ => None, + } +} + /// The appearance changes instance `n` makes to its symbol's art, without those that change nothing. fn own_changes(n: &Node) -> Vec<&SymbolOverride> { n.extras().symbol_overrides.iter().filter(|o| !o.is_empty()).collect() @@ -600,9 +751,10 @@ fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str /// Add the sets of appearance changes that the instances in `n` and its subtree make to the art of /// the symbols `sets` names, each set once, and those of the instances in the art of the symbols the -/// instances show (`walked`, the symbols already looked in). The instances that `left_out` picks or -/// that are in an object it picks are left out, and with `hidden` (in a layer) the hidden ones and -/// those in a hidden object. A symbol with more than [`MAX_ART_COPIES`] sets gets `None`. +/// instances show (`walked`, the symbols already looked in). With `hidden` (in a layer), the +/// instances that are hidden or that `left_out` picks, and those in such an object, are left out. +/// In symbol art every instance counts, since an instance of the symbol can change the objects +/// that hold it. A symbol with more than [`MAX_ART_COPIES`] sets gets `None`. fn drawn_changes<'a>( doc: &'a Document, n: &'a Node, @@ -611,7 +763,7 @@ fn drawn_changes<'a>( walked: &mut HashSet<&'a str>, sets: &mut HashMap<&'a str, Option>>>, ) { - if hidden && !n.visible || left_out(n) { + if hidden && (!n.visible || left_out(n)) { return; } if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index da6da069e9..77902b4a15 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art the page draws (non-printing layers left out unless includeNonPrinting or createLayers keeps them), as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art the page draws (non-printing layers left out unless includeNonPrinting or createLayers keeps them), as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, and with destination and an sRGB destination a gray or RGB image tagged with an ICC profile is converted from it and written untagged; with destination and a CMYK destination, or another RGB one images are converted to (not when colours are ICC-tagged), an RGB image tagged with one is converted from it through sRGB; printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index b0f08ca4c5..00eef89a11 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -205,7 +205,7 @@ pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Docu } /// [`page_document`] of `opened`, the PDF `bytes` already read. -fn opened_page_document(bytes: &[u8], opened: &vectorcraft_pdf::Opened, page: usize, o: &LoadOptions) -> Result<(Document, Vec)> { +pub(crate) fn opened_page_document(bytes: &[u8], opened: &vectorcraft_pdf::Opened, page: usize, o: &LoadOptions) -> Result<(Document, Vec)> { vectorcraft_pdf::import_opened(bytes, opened, &page_options(page, o)).map(|r| (r.document, r.warnings)).map_err(err) } @@ -217,8 +217,11 @@ fn page_options(page: usize, o: &LoadOptions) -> ImportOptions { /// `document.open` with `linkPages`: the pages `o` picks of the PDF (or PDF-compatible `.ai` or /// `.ait`) `name`, read from `path`, as a document holding one placed document per page, linked to /// that page of the file as File › Place with Link places one ([`document::source`]). Each page -/// gets an artboard of its `o.crop` box (its art's bounds for `bounding`), in a row from the left -/// with the gap a PDF's artboards open with, and a layer named after the page. The document opens +/// gets an artboard of its `o.crop` box (its art's bounds for `bounding`, a blank page's crop box; +/// a PDF-compatible `.ai` asked for its crop box gets its trim box, as it opens), in a row from the +/// left with the gap a PDF's artboards open with, and a layer named after the page. On its trim box, +/// a page shows its bleed box round the artboard, and the document's bleed is the pages' bleed (the +/// widest on each side); the artboards are then the gap apart plus the bleed on both sides. The document opens /// in CMYK when most of the pages read in CMYK, unless `o.color_mode` names a mode. Refused without a /// path, for any other file, with a password (a linked page is read again without one), with /// `textAs: outlines` (a linked page shows the file's own type) and for a file over @@ -239,12 +242,16 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa if bytes.len() > document::MAX_FILE { return Err(bad(C, format!("linkPages: `{file}` is over {} MB", document::MAX_FILE >> 20))); } - let count = vectorcraft_pdf::open_pdf(bytes, None).map_err(err)?.page_count(); + // The file is read once: every page is read from it. + let opened = vectorcraft_pdf::open_pdf(bytes, None).map_err(err)?; + let count = opened.page_count(); let picked = match &o.pages { None => (0..count).collect(), Some(range) => parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?, }; let bounding = o.crop == CropTo::Bounding; + // A PDF-compatible `.ai` asked for its crop box opens on its trim boxes ([`LoadOptions::import_options`]). + let crop = if o.crop == CropTo::Crop && opened.has_illustrator_data() { CropTo::Trim } else { o.crop }; let mut doc = Document::new(1.0, 1.0); let board = doc.artboards.first().cloned().ok_or_else(|| err("a new document has no artboard"))?; doc.artboards.clear(); @@ -253,14 +260,55 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa let gap = ImportOptions::default().artboard_gap; // Every page's object keeps the same one copy of the file. let shared = Arc::new(bytes.to_vec()); - let (mut x, mut warnings, mut cmyk_pages) = (0.0, Vec::::new(), 0); - for (i, &index) in picked.iter().enumerate() { + // The file is hashed once for all its pages. + let hash = link.hash.clone().unwrap_or_else(|| vectorcraft_doc::links::hash_bytes(bytes)); + let (mut warnings, mut cmyk_pages) = (Vec::::new(), 0); + // Each page as read, with its bleed (top, bottom, left, right). The pages are laid out once + // the document's bleed is known. + let mut read_pages = Vec::with_capacity(picked.len()); + for &index in &picked { let page = index as u32 + 1; - let s = document::shared_source(name, &shared, Some(path), page, bounding, document::page_box(o.crop), C)?; + let file_once = document::SharedFile { bytes: &shared, hash: &hash, opened: &opened }; + // On its trim box, a page shows its bleed box, the artboard on the trim box inside it, so + // the art in the bleed shows and prints as bleed. + let bleed = if crop == CropTo::Trim { opened.page_bleed(index) } else { None }; + let page_box = if bleed.is_some() { vectorcraft_doc::PageBox::Bleed } else { document::page_box(crop) }; + let read = |bounding| document::shared_source(name, file_once, Some(path), page, bounding, page_box, C); + // A blank page has no art's bounds: it keeps its crop box, as opening it as art does. + let (mut s, bounding) = match read(bounding) { + Err(e) if bounding && e.to_string().contains("has no art to place") => (read(false)?, false), + s => (s?, bounding), + }; + // A page that is only a placeholder (a `.ai` saved without PDF compatibility) shows the + // file's editing data, not a page of the file. + if s.editing_data { + return Err(bad(C, format!("linkPages: `{file}` isn't a PDF or a PDF-compatible .ai file"))); + } cmyk_pages += usize::from(s.color_mode == ColorMode::Cmyk); + // The document's bleed is the widest of the pages' on each side. + let sides = bleed.unwrap_or_default(); + for (side, b) in doc.setup.bleed.iter_mut().zip(sides) { + *side = side.max(b.min(vectorcraft_doc::setup::MAX_BLEED)); + } + for w in std::mem::take(&mut s.warnings) { + if !warnings.contains(&w) { + warnings.push(w); + } + } + read_pages.push((page, s, bounding, sides)); + } + // Each artboard is the gap from the one before plus the bleed on both sides of it: the + // document's bleed, which export adds round every artboard, or a page's own bleed box where it + // reaches farther. Neither page then shows or exports the other's art. + let [_, _, doc_left, doc_right] = doc.setup.bleed; + let mut x = 0.0; + for (i, (page, s, bounding, [top, bottom, left, right])) in read_pages.into_iter().enumerate() { + if i > 0 { + x += doc_left.max(left); + } let mut node = document::node(&mut doc, &s, LinkInfo { page: Some(page), ..link.clone() }, bounding); if let NodeKind::PlacedDocument(p) = &mut node.kind { - p.xf = Affine::translate((x, 0.0)); + p.xf = Affine::translate((x - left, -top)); } node.name = Some(file.clone()); let mut layer = Node::layer(doc.alloc_id(), &format!("Page {page}"), LayerColor::Preset((i % 27) as u8)); @@ -268,14 +316,9 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa children.push(Arc::new(node)); } doc.layers.push(Arc::new(layer)); - let rect = Rect::new(x, 0.0, x + s.frame.width(), s.frame.height()); + let rect = Rect::new(x, 0.0, x + s.frame.width() - left - right, s.frame.height() - top - bottom); doc.artboards.push(Artboard { id: i as u32 + 1, name: format!("Artboard {}", i + 1), rect, ..board.clone() }); - x = rect.x1 + gap; - for w in s.warnings { - if !warnings.contains(&w) { - warnings.push(w); - } - } + x = rect.x1 + doc_right.max(right) + gap; } // Pages painted mostly in CMYK read as CMYK documents, as opening them as art does: the // document opens in CMYK (with the CMYK default swatches) when most of its pages do. diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index c48b437b76..8b588975c4 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -1160,6 +1160,91 @@ fn an_ai_file_saved_without_its_pdf_part_places_from_its_editing_data() { assert!(e.contains("placeholder"), "{e}"); } +/// `document.open` with `linkPages` refuses a `.ai` saved without PDF compatibility, whose page is +/// only a placeholder, and opens a PDF-compatible one. +#[test] +fn link_pages_refuses_an_ai_file_saved_without_its_pdf_part() { + let d = std::env::temp_dir().join(format!("vc-ailinkpages-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let file = |name: &str, page: &str| { + let path = d.join(name); + std::fs::write(&path, ai::ai(&compressed(&ai::sample_data()), page)).unwrap(); + path.to_string_lossy().to_string() + }; + let (plain, compatible) = (file("plain.ai", PLACEHOLDER), file("compatible.ai", "0 g 10 10 40 40 re f")); + let mut s = Session::new(); + let e = s.execute("document.open", &json!({"path": plain, "linkPages": true})).unwrap_err().to_string(); + assert!(e.contains("isn't a PDF or a PDF-compatible .ai file"), "{e}"); + assert!(s.documents().is_empty(), "nothing opened"); + s.execute("document.open", &json!({"path": compatible, "linkPages": true})).unwrap(); + assert!(s.doc().unwrap().doc.has_placed()); + let _ = std::fs::remove_dir_all(&d); +} + +/// `document.open` with `linkPages` opens a PDF-compatible `.ai` asked for its crop box on its trim +/// box, as it opens as art, showing its bleed box round the artboard, which is the document's bleed. +#[test] +fn link_pages_open_an_ai_file_on_its_trim_box_with_its_bleed() { + let d = std::env::temp_dir().join(format!("vc-ailinkpagestrim-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let path = d.join("trimmed.ai"); + let boxes = "/TrimBox [10 10 190 90] /BleedBox [4 2 196 98]"; + std::fs::write(&path, ai::ai_with(&compressed(&ai::sample_data()), "0 g 10 10 40 40 re f", boxes)).unwrap(); + let path = path.to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": path, "linkPages": true})).unwrap(); + let doc = &s.doc().unwrap().doc; + let board = doc.artboards[0].rect; + assert_eq!((board.width(), board.height()), (180.0, 80.0), "the trim box"); + assert_eq!(doc.setup.bleed, [8.0, 8.0, 6.0, 6.0], "top, bottom, left, right"); + // The page shows its bleed box, so the art in the bleed is kept. + let placed = doc.layers[0].children().unwrap()[0].clone(); + let NodeKind::PlacedDocument(p) = &placed.kind else { panic!("{:?}", placed.kind) }; + assert_eq!((p.width, p.height, p.page_box), (192.0, 96.0, vectorcraft_doc::PageBox::Bleed)); + assert_eq!(p.xf.translation(), vectorcraft_geom::Vec2::new(-6.0, -8.0), "the trim box on the artboard"); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn link_pages_on_their_trim_boxes_keep_the_bleed_between_the_artboards() { + // Two 100 pt trim boxes: the first with 60 pt of bleed on each side, the second with 80 pt on + // its left, more than the largest document bleed (72 pt). + let entries = format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(2)); + let first = PdfPage { + bleed: Some([0.0, 0.0, 220.0, 220.0]), + trim: Some([60.0, 60.0, 160.0, 160.0]), + entries, + ..PdfPage::new(220.0, 220.0, "0 g 0 0 220 220 re f") + }; + let second = PdfPage { + bleed: Some([0.0, 0.0, 240.0, 220.0]), + trim: Some([80.0, 60.0, 180.0, 160.0]), + ..PdfPage::new(240.0, 220.0, "0 g 0 0 240 220 re f") + }; + let d = std::env::temp_dir().join(format!("vc-ailinkpagesgap-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let path = d.join("pages.ai"); + std::fs::write(&path, pdf_with(&[first, second], &["<< /Length 4 >>\nstream\ndata\nendstream"], None)).unwrap(); + let path = path.to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": path, "linkPages": true})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.setup.bleed, [60.0, 60.0, 72.0, 60.0], "top, bottom, left, right"); + // The first page's bleed ends at 160; the second's starts 36 pt later, 80 pt left of its trim box. + let boards: Vec<(f64, f64)> = doc.artboards.iter().map(|a| (a.rect.x0, a.rect.x1)).collect(); + assert_eq!(boards, [(0.0, 100.0), (276.0, 376.0)]); + let shown: Vec = doc + .layers + .iter() + .map(|l| match &l.children().unwrap()[0].kind { + NodeKind::PlacedDocument(p) => p.xf.translation().x, + k => panic!("{k:?}"), + }) + .collect(); + assert_eq!(shown, [-60.0, 196.0], "each page's bleed box round its artboard"); + let _ = std::fs::remove_dir_all(&d); +} + /// A page of text alone, as text-only art has in its PDF part: no path is painted. const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET"; diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index 82957468d8..8714072257 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -338,6 +338,152 @@ fn a_plot_of_the_arts_bounds_leaves_out_images_in_dynamic_symbol_art() { assert!(e.contains("no visible art"), "{e}"); } +/// A rectangle filled black, or one that paints nothing. +fn plot_rect(d: &mut vectorcraft_doc::Document, r: vectorcraft_geom::Rect, painted: bool) -> std::sync::Arc { + use vectorcraft_color::{Color, Paint}; + let fill = if painted { Paint::solid(Color::BLACK) } else { Paint::None }; + let appearance = vectorcraft_doc::Appearance::basic(fill, Paint::None, 0.0); + std::sync::Arc::new(vectorcraft_doc::Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), appearance)) +} + +/// An instance of symbol `symbol` placed by `xf`. +fn plot_instance(d: &mut vectorcraft_doc::Document, symbol: &str, xf: vectorcraft_geom::Affine) -> vectorcraft_doc::Node { + vectorcraft_doc::Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf }) +} + +/// Symbol `name` holding `art` in a group. +fn plot_symbol(d: &mut vectorcraft_doc::Document, name: &str, art: Vec>, dynamic: bool) { + let group = vectorcraft_doc::Node::group(d.alloc_id(), art); + d.symbols.push(vectorcraft_doc::Symbol { dynamic, name: name.into(), art: std::sync::Arc::new(group) }); +} + +/// Puts `n` on the first layer of `d`. +fn plot_on_layer(d: &mut vectorcraft_doc::Document, n: vectorcraft_doc::Node) { + std::sync::Arc::make_mut(&mut d.layers[0]).children_mut().unwrap().push(std::sync::Arc::new(n)); +} + +/// The art of a symbol with 9-slice guides, static or dynamic, is placed whole and measured +/// without what the plot leaves out: a frame that paints nothing sets where the guides place the +/// painted art inside it, and is not measured itself. +#[test] +fn a_plot_of_the_arts_bounds_places_9_slice_symbol_art_by_all_of_it() { + use vectorcraft_geom::{Affine, Rect}; + // Static and dynamic, and with the instance's fill staining the art. + for (dynamic, stained) in [(false, false), (true, false), (false, true)] { + let mut d = vectorcraft_doc::Document::new(400.0, 400.0); + let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false); + let square = plot_rect(&mut d, Rect::new(40.0, 40.0, 60.0, 60.0), true); + plot_symbol(&mut d, "Panel", vec![frame, square], dynamic); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + let mut wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + if stained { + let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + wide.appearance = vectorcraft_doc::Appearance::basic(red, vectorcraft_color::Paint::None, 0.0).into(); + } + plot_on_layer(&mut d, wide); + // Scaled 2 times across, the middle column maps x to 3x - 50: the square draws from x 70 + // to 130. + let b = super::export_art_bounds(&d, "hpgl").unwrap(); + let exact = [(b.x0, 70.0), (b.y0, 40.0), (b.x1, 130.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6); + assert!(exact, "dynamic: {dynamic}, stained: {stained}, {b:?}"); + } +} + +/// In art placed through 9-slice guides, a group with a fill of its own keeps the path it paints, +/// which paints nothing by itself. +#[test] +fn a_plot_of_the_arts_bounds_keeps_what_a_group_paints_in_9_slice_symbol_art() { + use vectorcraft_geom::{Affine, Rect}; + let mut d = vectorcraft_doc::Document::new(400.0, 400.0); + let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false); + let bare = plot_rect(&mut d, Rect::new(40.0, 40.0, 60.0, 60.0), false); + let mut group = vectorcraft_doc::Node::group(d.alloc_id(), vec![bare]); + let black = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK); + group.appearance = vectorcraft_doc::Appearance::basic(black, vectorcraft_color::Paint::None, 0.0).into(); + plot_symbol(&mut d, "Panel", vec![frame, std::sync::Arc::new(group)], false); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + let wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + plot_on_layer(&mut d, wide); + let b = super::export_art_bounds(&d, "hpgl").expect("the group's path is plotted"); + let exact = [(b.x0, 70.0), (b.y0, 40.0), (b.x1, 130.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6); + assert!(exact, "{b:?}"); +} + +/// A symbol without guides in the art of a symbol with 9-slice guides is measured without what the +/// plot leaves out, in the placed art and in an instance of its own. +#[test] +fn a_plot_of_the_arts_bounds_leaves_out_unplotted_art_of_symbols_in_9_slice_art() { + use vectorcraft_geom::{Affine, Rect}; + let mut d = vectorcraft_doc::Document::new(400.0, 400.0); + let far = plot_rect(&mut d, Rect::new(45.0, -500.0, 55.0, -480.0), false); + let dot = plot_rect(&mut d, Rect::new(45.0, 45.0, 55.0, 55.0), true); + plot_symbol(&mut d, "Dot", vec![far, dot], false); + let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false); + let inner = std::sync::Arc::new(plot_instance(&mut d, "Dot", Affine::IDENTITY)); + plot_symbol(&mut d, "Panel", vec![frame, inner], false); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + let wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + plot_on_layer(&mut d, wide); + let near = |b: Rect, e: [f64; 4]| [b.x0, b.y0, b.x1, b.y1].iter().zip(e).all(|(a, e)| (a - e).abs() < 1e-6); + // The dot's box is in the middle column, which maps x to 3x - 50. + let b = super::export_art_bounds(&d, "hpgl").unwrap(); + assert!(near(b, [85.0, 45.0, 115.0, 55.0]), "{b:?}"); + let own = plot_instance(&mut d, "Dot", Affine::translate((200.0, 0.0))); + plot_on_layer(&mut d, own); + let b = super::export_art_bounds(&d, "hpgl").unwrap(); + assert!(near(b, [85.0, 45.0, 255.0, 55.0]), "{b:?}"); +} + +/// The copy of a dynamic symbol that an instance's changes are measured on keeps the symbol's +/// 9-slice guides. +#[test] +fn a_plot_of_the_arts_bounds_measures_changed_9_slice_instances_by_their_guides() { + use vectorcraft_geom::{Affine, Rect}; + let mut d = vectorcraft_doc::Document::new(400.0, 400.0); + let bar = plot_rect(&mut d, Rect::new(10.0, 40.0, 70.0, 60.0), true); + plot_symbol(&mut d, "Bar", vec![bar], false); + let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false); + let inner = std::sync::Arc::new(plot_instance(&mut d, "Bar", Affine::IDENTITY)); + plot_symbol(&mut d, "Panel", vec![frame, inner], true); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + let mut wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + wide.extras_mut().symbol_overrides = + vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(vectorcraft_color::Paint::None), ..Default::default() }]; + plot_on_layer(&mut d, wide); + // The bar crosses the left guide and is placed by the middle column's map, x to 3x - 50: it + // draws from x -20 to 160. + let b = super::export_art_bounds(&d, "hpgl").unwrap(); + let exact = |b: Rect| [(b.x0, -20.0), (b.y0, 40.0), (b.x1, 160.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6); + assert!(exact(b), "{b:?}"); + // The same instance in the art of a symbol without guides. + let wide = std::sync::Arc::make_mut(&mut d.layers[0]).children_mut().unwrap().pop().unwrap(); + plot_symbol(&mut d, "Holder", vec![wide], false); + let holder = plot_instance(&mut d, "Holder", Affine::IDENTITY); + plot_on_layer(&mut d, holder); + let b = super::export_art_bounds(&d, "hpgl").unwrap(); + assert!(exact(b), "{b:?}"); +} + +/// An instance in a dynamic symbol's art, in a group at 0% opacity that an instance of the symbol +/// brings back to full opacity, is measured with the paint it gives its own symbol's art. +#[test] +fn a_plot_of_the_arts_bounds_measures_instances_that_an_instance_brings_back() { + use vectorcraft_geom::{Affine, Rect}; + let mut d = vectorcraft_doc::Document::new(400.0, 400.0); + let bare = plot_rect(&mut d, Rect::new(300.0, 300.0, 320.0, 320.0), false); + plot_symbol(&mut d, "Inner", vec![bare], true); + let mut inner = plot_instance(&mut d, "Inner", Affine::IDENTITY); + let black = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK); + inner.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(black), ..Default::default() }]; + plot_symbol(&mut d, "Outer", vec![std::sync::Arc::new(inner)], true); + std::sync::Arc::make_mut(&mut d.symbols[1].art).opacity = 0.0; + let mut outer = plot_instance(&mut d, "Outer", Affine::IDENTITY); + outer.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![], opacity: Some(1.0), ..Default::default() }]; + plot_on_layer(&mut d, outer); + let b = super::export_art_bounds(&d, "hpgl").expect("the painted rectangle is plotted"); + assert!((b.x0 - 300.0).abs() < 1e-6 && (b.x1 - 320.0).abs() < 1e-6, "{b:?}"); +} + /// A placed image with a Vector Halftone effect is measured when the effect makes dots, and left /// out when it would take more cells than the effect makes. #[test] diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 5f2a5e4070..31045e01c3 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -182,7 +182,8 @@ fn edit(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor let item = item_target(s, p, cmd)?; let stroke = edits_stroke(s, p, item, !s.fill_active, cmd)?; let item = item.of_kind(s, !stroke); - let ids = item.targets(s, p)?; + // The art of a graph design in a series keeps the design's gradient. + let ids = super::appearance::without_design_art(&s.doc()?.doc, item.targets(s, p)?, cmd)?; if ids.is_empty() { return Err(bad(cmd, "select an object with a freeform gradient")); } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 2fee4186cc..291aa1b59f 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -531,6 +531,10 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["angle"], C)?; let item = item.of_kind(s, !stroke); let ids = item.targets(s, p)?; + // Objects were given or selected, even when all of them are graph design art left out below: the stroke mode is then + // not one for new art. + let selected = !ids.is_empty(); + let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); // How the gradient lies on a stroke (the panel's Stroke buttons). let mode = match str_param(p, "strokeMode") { None => None, @@ -605,7 +609,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { Ok(()) })?; if let Some(m) = mode - && ids.is_empty() + && !selected && p.get("ids").is_none() && let Some(st) = s.new_art_stroke_mut() { @@ -760,6 +764,7 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { if targets.is_empty() { return Err(EngineError::Other("nothing selected".into())); } + let targets = super::appearance::drop_design_art(&s.doc()?.doc, targets); let model = super::paint::new_color_model(s, &Value::Null); edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| { if index.is_none() diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index b228341190..8a1c1daa4d 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -416,9 +416,186 @@ fn refused_on_design_art(s: &mut Session, cmd: &str, p: serde_json::Value) { let steps = s.doc().unwrap().history.undo.len(); let e = s.execute(cmd, &p).unwrap_err().to_string(); assert!(e.contains("drawn again from the design"), "{cmd}: {e}"); + // `appearance.addEffect` is an alias of `effect.apply`, whose errors name `effect.apply`. + let named = if cmd == "appearance.addEffect" { "effect.apply" } else { cmd }; + assert!(e.contains(&format!("`{named}`")), "the error names the command that ran: {e}"); assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}"); } +/// The look of object `id`: its appearance, opacity, blend mode, isolation, knockout and overprint. +fn look(s: &Session, id: NodeId) -> String { + format!("{:?}", s.doc().unwrap().doc.node(id).unwrap()) +} + +/// An edit of a graph design's art in a series lasts only until the graph is drawn again: the graph draws the art again +/// from the design. +#[test] +fn an_edit_of_a_graph_designs_art_is_lost_when_the_graph_is_drawn_again() { + let (mut s, id, _, art, _) = design_art(); + let path = |s: &Session| { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut out = None; + series(&n, 0).children().unwrap()[0].walk(&mut |m| { + if out.is_none() && matches!(m.kind, NodeKind::Path { .. }) { + out = Some(m.clone()); + } + }); + out.unwrap() + }; + assert_eq!(path(&s).id, art); + s.execute("object.setProps", &json!({"id": art.0, "opacity": 50})).unwrap(); + assert!((path(&s).opacity - 0.5).abs() < 1e-6); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"csv": ",2024,2025\nQ1,3,2\nQ2,5,8\nQ3,-1,6"})).unwrap(); + assert!((path(&s).opacity - 1.0).abs() < 1e-6); +} + +/// The paint, stroke, gradient and overprint commands leave the art of a graph design in a series as the design draws +/// it, and edit the other objects they are given. +#[test] +fn paint_commands_leave_a_graph_designs_art_out() { + let (mut s, _, copy, art, mark) = design_art(); + let fill = top_row(&s, art, true); + let commands = [ + ("stroke.set", json!({"weight": 5})), + ("stroke.weightUp", json!({})), + ("stroke.setAdvanced", json!({"arrowScale": [200, 200]})), + ("paint.editGradient", json!({"kind": "linear"})), + ("paint.setGradientGeom", json!({"start": [0, 0], "end": [50, 50]})), + ("object.setOverprint", json!({"fill": true})), + ("paint.swap", json!({})), + ("paint.default", json!({})), + ("paint.invert", json!({"item": fill, "stroke": false})), + ]; + let (copy_was, art_was) = (look(&s, copy), look(&s, art)); + for (cmd, p) in &commands { + for ids in [json!([copy.0]), json!([art.0])] { + let steps = s.doc().unwrap().history.undo.len(); + let mut q = p.clone(); + q["ids"] = ids; + let _ = s.execute(cmd, &q); + assert_eq!((look(&s, copy), look(&s, art)), (copy_was.clone(), art_was.clone()), "{cmd}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}"); + } + let mark_was = look(&s, mark); + let mut q = p.clone(); + q["ids"] = json!([art.0, mark.0]); + s.execute(cmd, &q).unwrap(); + assert_eq!(look(&s, art), art_was, "{cmd}"); + assert_ne!(look(&s, mark), mark_was, "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + } + // Selected, without ids. + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + s.execute("stroke.set", &json!({"weight": 5})).unwrap(); + assert_eq!(look(&s, art), art_was); + // A freeform gradient point is refused on the art alone. + refused_on_design_art(&mut s, "paint.freeform.addPoint", json!({"ids": [art.0], "at": [0, 0]})); +} + +/// `attributes.info` reads the overprint of a graph design's art in a series, which the overprint edits leave out. +#[test] +fn attributes_info_reads_a_graph_designs_art() { + let (mut s, _, _, art, _) = design_art(); + let info = s.execute("attributes.info", &json!({"ids": [art.0]})).unwrap(); + assert_eq!(info["ids"], json!([art.0])); + assert_eq!(info["overprintFill"], json!(false), "{info}"); + assert_eq!(info["overprintStroke"], json!(false), "{info}"); +} + +/// With only the art of a graph design selected, `stroke.setAdvanced` and the stroke mode of `paint.editGradient` act +/// as edits of a selection, not of the stroke the next object drawn takes. +#[test] +fn stroke_edits_of_selected_design_art_leave_the_new_art_stroke() { + let (mut s, _, _, art, _) = design_art(); + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + let was = format!("{:?}", s.new_art_stroke()); + s.execute("stroke.setAdvanced", &json!({"arrowScale": [200, 200], "swapArrows": true})).unwrap(); + assert_eq!(format!("{:?}", s.new_art_stroke()), was, "stroke.setAdvanced"); + let mode = s.new_art_stroke().gradient_mode; + s.execute("paint.editGradient", &json!({"kind": "linear", "stroke": true, "strokeMode": "along"})).unwrap(); + assert_eq!(s.new_art_stroke().gradient_mode, mode, "paint.editGradient"); +} + +/// `transparency.set` without `item` refuses the art of a graph design in a series, and given it with other objects +/// sets only theirs. +#[test] +fn object_transparency_leaves_a_graph_designs_art_out() { + let (mut s, _, copy, art, mark) = design_art(); + refused_on_design_art(&mut s, "transparency.set", json!({"ids": [art.0], "opacity": 40})); + refused_on_design_art(&mut s, "transparency.set", json!({"ids": [copy.0], "isolate": true})); + s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "opacity": 40, "blend": "multiply"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!((d.node(art).unwrap().opacity - 1.0).abs() < 1e-6); + assert!((d.node(mark).unwrap().opacity - 0.4).abs() < 1e-6); +} + +/// With the art of a graph design selected first, the Appearance panel's active row (a row of that art) stands for no +/// row of the other objects selected: an effect goes on each of them as a whole. +#[test] +fn an_active_row_of_a_graph_designs_art_is_not_applied_to_other_objects() { + let (mut s, _, _, art, mark) = design_art(); + let stroke = top_row(&s, art, false); + s.execute("select.set", &json!({"ids": [art.0, mark.0]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.subjects().first(), Some(&art)); + s.execute("appearance.setActiveItem", &json!({"index": stroke})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow"})).unwrap(); + let n = s.doc().unwrap().doc.node(mark).unwrap().clone(); + assert_eq!(n.appearance.effects.len(), 1); + assert!(n.appearance.items.iter().all(|it| it.effects().is_empty())); + assert!(s.doc().unwrap().doc.node(art).unwrap().appearance.effects.is_empty()); +} + +/// Expand Appearance leaves the art of a graph design in a series as the design draws it. +#[test] +fn expand_appearance_leaves_a_graph_designs_art() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [r]})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("graph.design", &json!({"new": "Shadow"})).unwrap(); + s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0], "design": "Shadow"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut art = vec![]; + series(&n, 0).children().unwrap()[0].walk(&mut |m| { + if !m.appearance.effects.is_empty() { + art.push(m.id); + } + }); + let art = art[0]; + let other = s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [other]})).unwrap(); + let art_was = look(&s, art); + // Given alone, by id or selected, it leaves Expand Appearance disabled. + for p in [json!({"ids": [art.0]}), json!({})] { + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + assert!(s.execute("effect.expandAppearance", &p).unwrap_err().to_string().contains("not available")); + assert_eq!(look(&s, art), art_was); + } + // Inside a selected graph or series group, the art stays as the design draws it: with nothing else to expand there, + // Expand Appearance is disabled, and with a mark's effect, the mark expands alone. + let series_group = series(&n, 0).id; + for root in [id, series_group] { + s.execute("select.set", &json!({"ids": [root.0]})).unwrap(); + assert!(s.execute("effect.expandAppearance", &json!({})).unwrap_err().to_string().contains("not available")); + assert_eq!(look(&s, art), art_was); + } + let mark = series(&n, 1).children().unwrap()[0].id; + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let expanded = s.execute("effect.expandAppearance", &json!({})).unwrap(); + assert_eq!(expanded["ids"], json!([mark.0])); + assert_eq!(look(&s, art), art_was); + s.execute("edit.undo", &json!({})).unwrap(); + // Selected with another object, the art is left as it is and the other object expands. + s.execute("select.set", &json!({"ids": [art.0, other]})).unwrap(); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + assert_eq!(look(&s, art), art_was); + assert!(s.doc().unwrap().doc.node(NodeId(other)).is_none_or(|m| m.appearance.effects.is_empty())); +} + /// The commands that give rows, effects or graphic styles refuse the art of a graph design inside a series, given by /// id (the design's copy or a path inside it) or selected: the graph draws that art again from the design. #[test] @@ -445,6 +622,7 @@ fn appearance_commands_refuse_a_graph_designs_art_in_a_series() { refused_on_design_art(&mut s, "effect.remove", json!({"ids": ids, "index": 0})); refused_on_design_art(&mut s, "threeD.mapGraphic", json!({"ids": ids, "symbol": "Mark"})); refused_on_design_art(&mut s, "graphicStyle.apply", json!({"name": "Blue", "ids": ids})); + refused_on_design_art(&mut s, "graphicStyle.breakLink", json!({"ids": ids})); } for cmd in ["appearance.removeItem", "appearance.duplicateItem"] { refused_on_design_art(&mut s, cmd, json!({"ids": [art.0], "index": fill})); @@ -499,6 +677,269 @@ fn appearance_commands_edit_the_objects_given_with_a_graph_designs_art() { assert_eq!(rows(&s, mark).items.len(), mark_was.items.len() + 1); } +/// `design_art()` with the design's art painted with a red and a blue fill and series 1's first mark with a blue fill and +/// a black stroke, so that row 1 of the art (its blue fill) is row 0 of the mark: the graph, the art and the mark. +fn aligned_design_art() -> (Session, NodeId, NodeId, NodeId) { + let (mut s, id, _, art, mark) = design_art(); + let (red, blue) = (Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)), Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0))); + let mark_look = vectorcraft_doc::Appearance::basic(blue, Paint::solid(vectorcraft_color::Color::BLACK), 1.0); + let mut art_look = mark_look.clone(); + art_look.items = vec![art_look.items[0].clone(), art_look.items[0].clone()]; + *art_look.items[0].paint_mut() = red; + s.edit("Looks", |d, _| { + d.node_mut(art).unwrap().appearance = vectorcraft_doc::Shared::new(art_look.clone()); + d.node_mut(mark).unwrap().appearance = vectorcraft_doc::Shared::new(mark_look.clone()); + Ok(()) + }) + .unwrap(); + (s, id, art, mark) +} + +/// Aligned rows given with the art of a graph design in a series first are rows of that art's stack, which the commands +/// leave as it is: each other object edits the row equal to the given one, never the row at the same index. +#[test] +fn aligned_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let green = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0)); + let (mut s, _, art, mark) = aligned_design_art(); + let (art_was, mark_was) = (rows(&s, art), rows(&s, mark)); + s.execute("appearance.removeItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(rows(&s, mark).items, mark_was.items[1..], "the mark loses its blue fill and keeps its stroke"); + s.execute("edit.undo", &json!({})).unwrap(); + + s.execute("appearance.setItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true, "color": "#00ff00"})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!((rows(&s, mark).items[0].paint(), &rows(&s, mark).items[1]), (&green, &mark_was.items[1])); + s.execute("edit.undo", &json!({})).unwrap(); + + s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + let fills: Vec = rows(&s, mark).items.iter().map(|it| it.is_fill()).collect(); + assert_eq!(fills, [true, true, false], "the mark's blue fill is copied, not its stroke"); + s.execute("edit.undo", &json!({})).unwrap(); + + s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 1, "aligned": true, "opacity": 40})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!((rows(&s, mark).items[0].opacity(), rows(&s, mark).items[1].opacity()), (0.4, 1.0)); + + // A row of the art past the end of the mark's stack. + let (mut s, _, art, mark) = aligned_design_art(); + s.edit("Rows", |d, _| { + let ap = &mut d.node_mut(art).unwrap().appearance; + let red = ap.items[0].clone(); + ap.items.insert(0, red); + Ok(()) + }) + .unwrap(); + s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 2, "aligned": true, "opacity": 40})).unwrap(); + assert_eq!((rows(&s, mark).items[0].opacity(), rows(&s, mark).items[1].opacity()), (0.4, 1.0)); + + // A row of the art (its red fill) that the mark has no equal of: transparency.set is an error, and appearance.setItem + // and appearance.removeItem leave the document as it is. + let (art_was, mark_was) = (rows(&s, art), rows(&s, mark)); + let steps = s.doc().unwrap().history.undo.len(); + let e = s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 0, "aligned": true, "opacity": 40})).unwrap_err(); + assert!(e.to_string().contains("no appearance item 0"), "{e}"); + s.execute("appearance.setItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true, "color": "#00ff00"})).unwrap(); + s.execute("appearance.removeItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true})).unwrap(); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was, mark_was)); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no undo step is recorded"); +} + +/// Aligned effects given with the art of a graph design in a series first go on, and come off, the rows equal to the art's +/// row in the other objects; an object without an equal row is left as it is. +#[test] +fn aligned_effects_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let effects = |s: &Session, id: NodeId| rows(s, id).items.iter().map(|it| it.effects().len()).collect::>(); + let (mut s, id, art, mark) = aligned_design_art(); + // A second mark of series 1 with a green fill, which no row of the art equals, and the same black stroke. + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let other = series(&n, 1).children().unwrap()[1].id; + let mut other_look = rows(&s, mark); + *other_look.items[0].paint_mut() = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0)); + s.edit("Look", |d, _| { + d.node_mut(other).unwrap().appearance = vectorcraft_doc::Shared::new(other_look.clone()); + Ok(()) + }) + .unwrap(); + let (art_was, other_was) = (rows(&s, art), rows(&s, other)); + let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(effects(&s, mark), [1, 0], "the shadow goes on the mark's blue fill"); + assert_eq!(rows(&s, other), other_was, "the other mark has no blue fill"); + assert_eq!((&r["ids"], &r["item"], &r["index"]), (&json!([mark.0]), &json!(0), &json!(0)), "the reply names the mark's row"); + s.execute("edit.undo", &json!({})).unwrap(); + // A 3D effect is checked on the rows it goes on only. + s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap(); + assert_eq!((rows(&s, art), effects(&s, mark), rows(&s, other)), (art_was.clone(), vec![1, 0], other_was.clone())); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("appearance.addEffect", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap(); + assert_eq!((rows(&s, art), effects(&s, mark), rows(&s, other)), (art_was, vec![1, 0], other_was)); + + // The art's blue fill with the same shadow as the mark's: removing it from the art's row takes it off the mark's. + s.edit("Shadow", |d, _| { + let row = d.node(mark).unwrap().appearance.items[0].clone(); + d.node_mut(art).unwrap().appearance.items[1] = row; + Ok(()) + }) + .unwrap(); + let art_was = rows(&s, art); + // The effect dialog reads the shadow from the art's row and passes the art on first. + let (target, fx) = s.appearance_effect_target(&json!({"ids": [art.0, mark.0], "item": 1, "aligned": true})).unwrap(); + assert_eq!((fx.len(), &target["ids"], &target["item"]), (1, &json!([art.0, mark.0]), &json!(1))); + // Edits of the art's row effect change the mark's equal row. + s.execute("effect.setParams", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "visible": false, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert!(!rows(&s, mark).items[0].effects()[0].visible, "the mark's shadow is hidden"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("effect.duplicate", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "aligned": true})).unwrap(); + assert_eq!((rows(&s, art), effects(&s, mark)), (art_was.clone(), vec![2, 0])); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(effects(&s, mark), [0, 0]); + + // An object-level effect of the art that the mark does not have: a new object-level effect still goes on the mark. + s.edit("Effect", |d, _| { + let shadow = d.node(art).unwrap().appearance.items[1].effects()[0].clone(); + d.node_mut(art).unwrap().appearance.effects.push(shadow); + Ok(()) + }) + .unwrap(); + let art_was = rows(&s, art); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [art.0, mark.0], "item": null, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(rows(&s, mark).effects.len(), 1); + // The art's object-level shadow is not the mark's roughen: the effect dialog reads the art's, and removing it leaves + // the mark as it is. + let (_, fx) = s.appearance_effect_target(&json!({"ids": [art.0, mark.0], "item": null, "aligned": true})).unwrap(); + assert_eq!(fx.iter().map(|e| e.id.to_string()).collect::>(), ["stylize.dropShadow"]); + let mark_was = rows(&s, mark); + assert!(s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 0, "aligned": true})).is_err()); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was)); + // An object-level effect index past the end of the art's list names no effect of the mark either. + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap(); + let mark_was = rows(&s, mark); + assert!(s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 1, "aligned": true})).is_err()); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was)); + // The mark with the art's object-level shadow after its roughen: removing the art's effect 0 takes off the mark's 1. + s.edit("Shadow", |d, _| { + let shadow = d.node(art).unwrap().appearance.effects[0].clone(); + d.node_mut(mark).unwrap().appearance.effects[1] = shadow; + Ok(()) + }) + .unwrap(); + s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 0, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(rows(&s, mark).effects.iter().map(|e| e.id.to_string()).collect::>(), ["distort.roughen"]); +} + +/// Aligned rows of a graph design's art and of another object whose stack differs are not one place in both stacks, so +/// they are not reordered, and `to` is refused for their copies; with the same stacks, the copies go to `to`. +#[test] +fn aligned_rows_of_a_graph_designs_art_and_a_different_stack_are_not_moved() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let (mut s, _, art, mark) = aligned_design_art(); + let mark_was = rows(&s, mark); + assert!(s.execute("appearance.moveItem", &json!({"ids": [art.0, mark.0], "from": 1, "to": 0, "aligned": true})).is_err()); + assert_eq!(rows(&s, mark), mark_was); + assert!(s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "to": 0, "aligned": true})).is_err()); + assert_eq!(rows(&s, mark), mark_was); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap(); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [mark.0], "item": null})).unwrap(); + let mark_was = rows(&s, mark); + assert!(s.execute("effect.move", &json!({"ids": [art.0, mark.0], "from": 1, "to": 0, "aligned": true})).is_err()); + assert_eq!(rows(&s, mark), mark_was); + + // The mark with the art's stack. + let (mut s, _, art, mark) = aligned_design_art(); + let art_was = rows(&s, art); + s.edit("Look", |d, _| { + d.node_mut(mark).unwrap().appearance = vectorcraft_doc::Shared::new(art_was.clone()); + Ok(()) + }) + .unwrap(); + assert!(s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "indices": [1, 9], "to": 0, "aligned": true})).is_err()); + assert_eq!(rows(&s, mark), art_was, "a row that does not exist is refused"); + s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "to": 0, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + let paints: Vec<_> = rows(&s, mark).items.iter().map(|it| it.paint().clone()).collect(); + assert_eq!(paints, [art_was.items[1].paint().clone(), art_was.items[0].paint().clone(), art_was.items[1].paint().clone()]); +} + +/// The Contents row of a graph design's copy and of a group whose stack differs is not moved with `aligned: true`, as +/// the rows are not. +#[test] +fn aligned_contents_row_of_a_graph_designs_copy_and_a_different_stack_is_not_moved() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let (mut s, _, copy, _, _) = design_art(); + s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("appearance.addFill", &json!({"ids": [g.0]})).unwrap(); + s.execute("appearance.addFill", &json!({"ids": [g.0]})).unwrap(); + assert_ne!(rows(&s, copy), rows(&s, g)); + let (copy_was, g_was) = (rows(&s, copy), rows(&s, g)); + let steps = s.doc().unwrap().history.undo.len(); + let e = s.execute("appearance.moveItem", &json!({"ids": [copy.0, g.0], "from": "contents", "to": 1, "aligned": true})).unwrap_err(); + assert!(e.to_string().contains("appearances differ"), "{e}"); + assert_eq!((rows(&s, copy), rows(&s, g)), (copy_was, g_was)); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); +} + +/// Invert with an `item` remembers the paint of an object it recolored, not that of a graph design's art given first, +/// which it leaves out. +#[test] +fn invert_of_an_item_remembers_the_recolored_paint_not_the_design_arts() { + let (mut s, _, art, mark) = aligned_design_art(); + s.execute("paint.invert", &json!({"ids": [art.0, mark.0], "item": 0, "stroke": false})).unwrap(); + let Paint::Solid { color, .. } = s.doc().unwrap().doc.node(mark).unwrap().appearance.items[0].paint().clone() else { + panic!("the mark's fill stays a solid color"); + }; + assert_ne!(color, vectorcraft_color::Color::rgb(0.0, 0.0, 1.0), "the mark's blue fill is inverted"); + assert_eq!(s.last_solid, color, "the mark's inverted fill is remembered, not the art's red one"); +} + +/// With a graph design's art given first and `aligned: true`, `effect.remove` and `effect.duplicate` with `indices` are an +/// error and change nothing when one of the indices names no effect of the art. +#[test] +fn aligned_effect_indices_past_the_end_of_a_graph_designs_art_are_refused() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let (mut s, _, art, mark) = aligned_design_art(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap(); + s.edit("Shadow", |d, _| { + let shadow = d.node(mark).unwrap().appearance.effects[0].clone(); + d.node_mut(art).unwrap().appearance.effects.push(shadow); + Ok(()) + }) + .unwrap(); + let (art_was, mark_was) = (rows(&s, art), rows(&s, mark)); + let steps = s.doc().unwrap().history.undo.len(); + for cmd in ["effect.remove", "effect.duplicate"] { + let e = s.execute(cmd, &json!({"ids": [art.0, mark.0], "item": null, "indices": [0, 9], "aligned": true})).unwrap_err(); + assert!(e.to_string().contains("no effects at indices 0, 9"), "{cmd}: {e}"); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was.clone()), "{cmd}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}"); + } + // With index 0 alone, which the art has, the mark's equal effect is removed. + s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "indices": [0], "aligned": true})).unwrap(); + assert!(rows(&s, mark).effects.is_empty()); +} + +/// A blur on a series group makes the whole group an image, which draws the design's art with the series' other art: +/// Expand Appearance does not put the art back beside the image. +#[test] +fn expanding_a_blurred_series_group_takes_a_graph_designs_art_into_the_image() { + let (mut s, id, copy, _, _) = design_art(); + let series_group = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; + s.execute("effect.apply", &json!({"effect": "blur.gaussian", "ids": [series_group.0]})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids": [series_group.0]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(matches!(d.node(series_group).unwrap().kind, NodeKind::Image(_))); + assert!(d.node(copy).is_none()); +} + /// The paint commands leave the art of a graph design in a series out, a copy of the design given by id with an item /// included. #[test] diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index 88362d7c49..25a831622e 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -158,6 +158,9 @@ fn apply_transform_moving_guides( } None => ids.clone(), }; + // Pattern editing with Move Tile with Art: the tile art's bounds before a move. + let moves_only = c[0] == 1.0 && c[1] == 0.0 && c[2] == 0.0 && c[3] == 1.0; + let tile_art = if objects && !copy && moves_only { crate::cmd::patterncmds::tile_art_bounds(d) } else { None }; // Transforms don't move nodes in the tree, so reuse the paths found for the no-copy // case; copies and a settled typing session need paths from the edited document. let paths = if reindex { d.index_paths(targets.iter().copied()) } else { paths }; @@ -172,6 +175,10 @@ fn apply_transform_moving_guides( for id in &targets { super::pixelgrid::keep_compound_aligned(d, *id, sc); } + // Moving all of the tile art moves the tile as far. + if let Some(before) = tile_art { + crate::cmd::patterncmds::move_tile_with_art(d, &targets, before); + } } if with_guides && (delta.x != 0.0 || delta.y != 0.0) { for i in sel.guides.clone() { diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 4cc1fa24c3..8149701334 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -156,10 +156,15 @@ pub(super) fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Doc pub(super) type OverprintJob = ((bool, ItemTarget, Vec), bool); /// The overprint edits `p` asks for under `keys` (the fill's and the stroke's), aimed as -/// `object.setOverprint` aims. +/// `object.setOverprint` aims, leaving out the art of graph designs drawn in graph series. pub(super) fn overprint_jobs(s: &Session, p: &Value, cmd: &str, keys: [&str; 2]) -> Result> { let kinds = kinds(p, cmd, keys)?; - Ok(aims(s, p, cmd, kinds.iter().map(|k| k.0))?.into_iter().zip(kinds.iter().map(|k| k.1)).collect()) + let d = &s.doc()?.doc; + Ok(aims(s, p, cmd, kinds.iter().map(|k| k.0))? + .into_iter() + .map(|(fill, it, ids)| (fill, it, super::appearance::drop_design_art(d, ids))) + .zip(kinds.iter().map(|k| k.1)) + .collect()) } /// Apply `jobs` to `d`, adding the objects changed to `changed`. diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index ccdb86197b..735f89db83 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, 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)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke, and on a series group without an item, the group's own opacity, blend mode, isolate, knockout and knockoutShape (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, 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)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke, and on a series group without an item, the group's own opacity, blend mode, isolate, knockout and knockoutShape (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", has_doc, transparency ), @@ -966,7 +966,7 @@ pub(super) fn restore_unpainted_stroke(ap: &mut Appearance, index: Option } fn swap(s: &mut Session, p: &Value) -> Result { - let ids = paint_targets(s, p)?; + let ids = super::appearance::drop_design_art(&s.doc()?.doc, paint_targets(s, p)?); if !ids.is_empty() { // When every object comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); @@ -1001,7 +1001,7 @@ fn swap(s: &mut Session, p: &Value) -> Result { } fn default_paint(s: &mut Session, p: &Value) -> Result { - let ids = paint_targets(s, p)?; + let ids = super::appearance::drop_design_art(&s.doc()?.doc, paint_targets(s, p)?); if !ids.is_empty() { // When every object comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); @@ -1070,9 +1070,9 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( return Ok(json!({ "changed": changed as usize })); } let item = item_target(s, p, cmd)?.of_kind(s, !stroke); - let (r, shown_item) = match item { + let (r, shown_item, edited) = match item { ItemTarget::Item { index, .. } => { - let ids = item.targets(s, p)?; + let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); let mut changed = 0; edit_items(s, &ids, item, cmd, label, !stroke, |n, i| { let paint = @@ -1080,15 +1080,19 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize; Ok(()) })?; - (json!({ "changed": changed }), Some(index)) + (json!({ "changed": changed }), Some(index), Some(ids)) } // The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles. ItemTarget::Top => { let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::>()}); - (super::colorcmds::recolor(s, &q, cmd, label, f)?, None) + (super::colorcmds::recolor(s, &q, cmd, label, f)?, None, None) } }; - let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? }; + // The paint remembered is one the edit recolored: a graph design's art that the item edit leaves out gives none. + let shown_ids = match edited { + Some(ids) => ids, + None => leaf_targets(s, &ids)?, + }; if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) { s.remember_paint(&shown); } @@ -1142,6 +1146,9 @@ fn transparency(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select objects or give ids")); } + // The object-level opacity, blend mode, isolation and knockout of a graph design's art in a series are drawn again + // from the design too. + let ids = super::appearance::without_design_art(&s.doc()?.doc, ids, C)?; // While an opacity mask is edited the selection is its art, so the Appearance panel's active // item (a row of that art) doesn't stand for the masked object. let editing_mask = p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_some(); @@ -1168,13 +1175,22 @@ fn transparency(s: &mut Session, p: &Value) -> Result { return s.execute("object.setProps", &Value::Object(q)); }; // Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level. - let item_ids = if editing_mask { ids } else { row_targets(s, p)? }; + let item_ids = if editing_mask { ids } else { row_targets(s, p, C)? }; + // The art of a graph design whose stack an explicit aligned item is a row of goes first: appearance.setItem leaves + // it as it is and matches the other objects' rows against it. + let of = if editing_mask || !explicit { None } else { super::appearance::design_reference(s, p, C)? }; if explicit { let d = &s.doc()?.doc; - if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) { + // With such art, an object has the item when it has a row equal to the art's. + let found = match of { + Some(_) => super::appearance::rows_per_target(d, of, &item_ids, &[index], true).iter().any(|r| !r.is_empty()), + None => item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())), + }; + if !found { return Err(bad(C, format!("no appearance item {index}"))); } } + let item_ids = super::appearance::with_reference(of, &item_ids); let (opacity, blend) = (q.remove("opacity"), q.remove("blend")); if let Some(b) = &blend && b.as_str().and_then(BlendMode::parse).is_none() diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index fa0492c2db..8f4e3c71f4 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options, as text.setRangeStyle: the share of the blank space beside each character's ink taken away in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options, as text.setRangeStyle: the characters drawn in one OpenType form of the font, or its default glyphs), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options, as text.setRangeStyle: the share of the blank space beside each character's ink taken away in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options, as text.setRangeStyle: the characters drawn in one OpenType form of the font, or its default glyphs; a form other than \"default\" takes away their glyph choice), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -824,7 +824,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { st.digits = v; } if let Some(v) = glyph_form { - st.glyph_form = v; + st.set_glyph_form(v); } if let Some(v) = &language { st.language.clone_from(v); diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 2800e8e674..57f9094e48 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -78,7 +78,7 @@ pub fn specs() -> Vec { "Pattern Options", ["Window", "Pattern Options"], None, - "{name? (default: the pattern being edited), newName? (trimmed; blank: the name is kept), tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}", + "{name? (default: the pattern being edited), newName? (trimmed; blank: the name is kept), tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, moveTileWithArt?: bool (in pattern editing mode, a move of all of the tile art moves the tile with it; off for a new pattern), overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}", has_doc, pattern_options ), @@ -530,7 +530,7 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { let cur = s.doc()?.doc.pattern(&name).cloned().ok_or_else(|| bad(C, format!("no pattern `{name}`")))?; let tt = tile_type_param(p, C, cur.tile_type)?; check_numbers(p, &["width", "height", "hSpacing", "vSpacing", "copies", "dimCopies"], C)?; - check_switches(p, &["sizeTileToArt", "showTileEdge", "showSwatchBounds"], C)?; + check_switches(p, &["sizeTileToArt", "moveTileWithArt", "showTileEdge", "showSwatchBounds"], C)?; let num = |k: &str| p.get(k).and_then(Value::as_f64); for k in ["width", "height"] { if num(k).is_some_and(|v| !(v.is_finite() && v > 0.0)) { @@ -560,11 +560,11 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { }; let edit_layer = editing(s).filter(|e| e.pattern == name).map(|e| e.layer); let out_name = new_name.clone().unwrap_or(name.clone()); - s.edit("Pattern Options", |d, _| { - // While editing, the tile art is the edit layer's. - let live_art: Option>> = - edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()); - let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?; + // While editing, the tile art is the edit layer's. + let live_art = |d: &Document| -> Option>> { + edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()) + }; + let apply = |def: &mut PatternDef, live_art: Option>>| { def.tile_type = tt; def.overlap = overlap; if let Some(v) = num("hSpacing") { @@ -576,6 +576,9 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("sizeTileToArt").and_then(Value::as_bool) { def.size_tile_to_art = v; } + if let Some(v) = p.get("moveTileWithArt").and_then(Value::as_bool) { + def.move_tile_with_art = v; + } if let Some(v) = num("copies") { def.copies = (v as u32).clamp(1, 15) | 1; } @@ -590,13 +593,24 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { } if def.size_tile_to_art { let saved = std::mem::take(&mut def.art); - def.art = live_art.clone().unwrap_or_else(|| saved.clone()); + def.art = live_art.unwrap_or_else(|| saved.clone()); def.fit_tile_to_art(); def.art = saved; } else if num("width").is_some() || num("height").is_some() { let c = def.tile.center(); def.tile = Rect::from_center_size(c, (num("width").unwrap_or(def.tile.width()), num("height").unwrap_or(def.tile.height()))); } + }; + // Options that change nothing leave the document and its undo history as they are. + let mut next = cur.clone(); + apply(&mut next, live_art(&s.doc()?.doc)); + if next == cur && new_name.is_none() { + return Ok(json!({ "name": out_name })); + } + s.edit("Pattern Options", |d, _| { + let live = live_art(d); + let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?; + apply(def, live); if let Some(nn) = &new_name { def.name = nn.clone(); rename_pattern_uses(d, &name, nn); @@ -609,6 +623,47 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { Ok(json!({ "name": out_name })) } +/// The tile art's objects (the editing layer's, ruler guides aside) while `d` edits a pattern that +/// has Move Tile with Art on, with the pattern's name. +fn moving_tile_art(d: &Document) -> Option<(String, Vec>)> { + let pe = d.pattern_edit.as_ref()?; + if !d.pattern(&pe.pattern).is_some_and(|p| p.move_tile_with_art) { + return None; + } + let art = d.node(pe.layer)?.children()?.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(); + Some((pe.pattern.clone(), art)) +} + +/// Before a move: the bounds of the tile art for [`move_tile_with_art`], `None` unless `d` edits a +/// pattern with Move Tile with Art on. +pub(crate) fn tile_art_bounds(d: &Document) -> Option { + moving_tile_art(d).and_then(|(_, art)| vectorcraft_doc::live::nodes_bounds(&art)) +} + +/// Move Tile with Art: after a move of `moved` that takes every object of the tile art (a group +/// counting when all of its objects moved), the tile moves by as much as the art did, measured from +/// its bounds `before` the move (objects aligned to the pixel grid snap back to it). A move of only +/// some of the art leaves the tile where it is. +pub(crate) fn move_tile_with_art(d: &mut Document, moved: &[NodeId], before: Rect) { + let Some((name, art)) = moving_tile_art(d) else { return }; + let moved: std::collections::HashSet = moved.iter().copied().collect(); + // An object moves whole when it moved, or when every object in it did (a group whose members + // were selected and moved one by one). + fn whole(n: &Node, moved: &std::collections::HashSet) -> bool { + moved.contains(&n.id) || n.children().is_some_and(|ch| !ch.is_empty() && ch.iter().all(|c| whole(c, moved))) + } + if art.is_empty() || !art.iter().all(|c| whole(c, &moved)) { + return; + } + let Some(after) = vectorcraft_doc::live::nodes_bounds(&art) else { return }; + let by = after.origin() - before.origin(); + if (by.x != 0.0 || by.y != 0.0) + && let Some(def) = d.pattern_mut(&name) + { + def.tile = def.tile + by; + } +} + /// Side `side` of the `overlap` object `o` gives `cmd`: true for the second of `names`, false for the /// first, `current` when `o` leaves it out or gives null. Any other value is an error. fn overlap_side(o: &Value, side: &str, names: [&str; 2], current: bool, cmd: &str) -> Result { @@ -680,6 +735,7 @@ fn pattern_list(s: &mut Session, _: &Value) -> Result { "hSpacing": p.h_spacing, "vSpacing": p.v_spacing, "sizeTileToArt": p.size_tile_to_art, + "moveTileWithArt": p.move_tile_with_art, "overlap": {"h": if p.overlap.right_in_front { "right" } else { "left" }, "v": if p.overlap.bottom_in_front { "bottom" } else { "top" }}, "copies": p.copies, "dimCopies": p.dim_copies, diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 8c9a19cb8d..0567be7e59 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -257,7 +257,7 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result, link_pdf: bool) -> Result), path: Option<&str>, page: usize, opts: fileio::LoadOptions, @@ -321,11 +323,15 @@ pub(crate) fn pdf_art( let name = fileio::file_name(src); let index = page.checked_sub(1).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))?; let o = fileio::LoadOptions { crop: if bounding { CropTo::Crop } else { opts.crop }, ..opts }; - let mut read = fileio::page_document(bytes, index, &o); + let page_document = |i: usize| match opened { + Some(opened) => fileio::opened_page_document(bytes, opened, i, &o), + None => fileio::page_document(bytes, i, &o), + }; + let mut read = page_document(index); // Such a file can have more artboards than placeholder pages. if page > 1 && read.is_err() - && let Err(e) = fileio::page_document(bytes, 0, &o) + && let Err(e) = page_document(0) && fileio::is_placeholder(&e) { read = Err(e); @@ -351,7 +357,7 @@ pub(crate) fn pdf_art( let clip = (!bounding).then_some(board); let frame = clip.or_else(|| shown_bounds(&symbols, &nodes)); let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?; - Ok(document::DocumentArt { doc, nodes, clip, frame, warnings }) + Ok(document::DocumentArt { doc, nodes, clip, frame, warnings, editing_data: alone }) } /// The bounds of what `nodes` draw, as an export measures art diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 5809990e0f..0d9a1470a8 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -100,6 +100,9 @@ pub(crate) struct DocumentArt { /// What is shown: the artboard, or the art's bounds. pub frame: Rect, pub warnings: Vec, + /// Whether a PDF or `.ai` page was read from the file's editing data (its page only a + /// placeholder, or its PDF part unreadable) instead of from the page. + pub editing_data: bool, } /// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when @@ -114,6 +117,21 @@ pub(crate) fn document_art( bounding: bool, page_box: PageBox, cmd: &str, +) -> Result { + opened_document_art(name, (bytes, None), path, page, bounding, page_box, cmd) +} + +/// [`document_art`] of `bytes` and, for a PDF already read, `opened`: the PDF `bytes` as +/// [`vectorcraft_pdf::open_pdf`] read it without a password, so its page is read without reading +/// the file again. +fn opened_document_art( + name: &str, + (bytes, opened): (&[u8], Option<&vectorcraft_pdf::Opened>), + path: Option<&str>, + page: u32, + bounding: bool, + page_box: PageBox, + cmd: &str, ) -> Result { let file = fileio::file_name(name); let pdf = is_pdf(name, bytes); @@ -125,7 +143,7 @@ pub(crate) fn document_art( } if pdf { let o = fileio::LoadOptions { crop: crop_to(page_box), ..Default::default() }; - return super::pdf_art(name, bytes, path, page as usize, o, bounding, cmd); + return super::pdf_art(name, (bytes, opened), path, page as usize, o, bounding, cmd); } let mut l = fileio::load(name, bytes)?; // Its linked images show their files; its placed documents stay as they are, unless their @@ -145,7 +163,7 @@ pub(crate) fn document_art( let nodes = super::on_artboard(&symbols, &layers, board); let frame = if bounding { super::shown_bounds(&symbols, &nodes) } else { Some(board) }; let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{file}` has no art to place")))?; - Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings }) + Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings, editing_data: false }) } /// The most placed documents read inside one another while their links are resolved. @@ -223,6 +241,8 @@ pub(crate) struct Source { pub warnings: Vec, /// The colour mode the file's art was read in (a PDF page painted mostly in CMYK reads in CMYK). pub color_mode: ColorMode, + /// [`DocumentArt::editing_data`]. + pub editing_data: bool, } /// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing @@ -232,33 +252,48 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo read_source(name, bytes, None, path, (page, bounding, page_box), cmd) } -/// [`source`] of the file whose bytes `bytes` holds, kept by the blob without a copy of its own: -/// several pages of one file read this way share one copy of it. +/// A PDF (or PDF-compatible `.ai`) whose pages are read for placing linked, read once for them all. +#[derive(Clone, Copy)] +pub(crate) struct SharedFile<'a> { + /// The file's bytes, which every page's blob keeps without a copy of its own. + pub bytes: &'a Arc>, + /// The bytes' hash ([`hash_bytes`]). + pub hash: &'a str, + /// The bytes as [`vectorcraft_pdf::open_pdf`] read them, without a password. + pub opened: &'a vectorcraft_pdf::Opened, +} + +/// [`source`] of page `page` of `file`: several pages of one file read this way share one copy of +/// it, and the file is hashed and read once for them all. pub(crate) fn shared_source( name: &str, - bytes: &Arc>, + file: SharedFile<'_>, path: Option<&str>, page: u32, bounding: bool, page_box: PageBox, cmd: &str, ) -> Result { - read_source(name, bytes, Some(bytes), path, (page, bounding, page_box), cmd) + read_source(name, file.bytes, Some(file), path, (page, bounding, page_box), cmd) } -/// [`source`], the blob keeping `shared` when given (else a copy of `bytes`); `shown` is the page -/// (or artboard), whether the art's bounds are shown, and the page's box. +/// [`source`], the blob keeping `shared`'s bytes when given (else a copy of `bytes`), its hash +/// used for the key (else `bytes` hashed) and its pages read from what was read of it; `shown` is +/// the page (or artboard), whether the art's bounds are shown, and the page's box. fn read_source( name: &str, bytes: &[u8], - shared: Option<&Arc>>, + shared: Option>, path: Option<&str>, shown: (u32, bool, PageBox), cmd: &str, ) -> Result { let (page, bounding, page_box) = shown; + let hash = shared.map_or_else(|| hash_bytes(bytes), |f| f.hash.to_string()); + let opened = shared.map(|f| f.opened); + let shared = shared.map(|f| f.bytes); // The same document placed the same way is read once (placing it again is free). - let key = key_for(&hash_bytes(bytes), page, bounding, page_box); + let key = key_for(&hash, page, bounding, page_box); // Resolving its links depends on where the file is and on what the caller may read, so a hit // needs the same path, and nothing is shared with or from a confined (automation) caller. let confined = crate::file_access::current().is_some(); @@ -269,7 +304,7 @@ fn read_source( } return Ok(s); } - let a = document_art(name, bytes, path, page, bounding, page_box, cmd)?; + let a = opened_document_art(name, (bytes, opened), path, page, bounding, page_box, cmd)?; let bytes = shared.cloned().unwrap_or_else(|| Arc::new(bytes.to_vec())); let mut blob = ImageBlob { mime: mime(name, &bytes).into(), bytes, proxy: None }; let shown = PlacedDocument { @@ -284,10 +319,10 @@ fn read_source( }; let art = group(a.nodes, a.clip); blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new); - let color_mode = a.doc.color_mode; + let (color_mode, editing_data) = (a.doc.color_mode, a.editing_data); // Drawing it needn't read it again. vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame)); - let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings, color_mode }; + let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings, color_mode, editing_data }; if !confined { let mut m = lock_memo(); if m.len() >= MEMO_SIZE { diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 385fd071c4..5b9e911b0a 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -11,7 +11,7 @@ use vectorcraft_scriptcheck::{Limits, Loaded, Loader, Report}; /// The ids a reply can hold, for the windows that show it. pub use vectorcraft_scriptcheck::{IncludeStatus, Need, OUTSIDE, PROBLEMS}; -use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, str_param}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, str_param, switch_or}; use crate::script; /// The extensions of the scripts File › Open shows a check for. @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Run Script", [], None, - "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file of the code that reads it (the file being run for code that eval or Function compiled; empty for code compiled from a script given as text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; an error in code an earlier run compiled is at that run's file, line and column, within 8 MiB of source maps kept for each engine, the oldest going first; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script, or in code an earlier run of its engine compiled), fuelLeft, fuelUsed, hostCalls and the fields above", + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), installed?: true or false (default false; true with a path that script.listInstalled gave: the file is read once, checked as File › Scripts checks it, and that text runs, its #include lines and $.evalFile reading in the folder that read found; a file that is no longer a listed script ends with \"the script is no longer in the Scripts folder\" and nothing runs), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file of the code that reads it (the file being run for code that eval or Function compiled; empty for code compiled from a script given as text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; an error in code an earlier run compiled is at that run's file, line and column, within 8 MiB of source maps kept for each engine, the oldest going first; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script, or in code an earlier run of its engine compiled), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), @@ -154,8 +154,17 @@ fn run(s: &mut Session, p: &Value) -> Result { return Err(param("`answers` is only for a run with nobody to ask: in the app, the person answers each dialog")); } let fuel = max_fuel(p)?; + let installed = switch_or(p, "installed", false, "script.run")?; + let mut checked_real = None; let text = match (path, source) { (Some(""), None) => return Err(param("`path` is empty")), + // The text runs as this one read checked it, and its folder is the one that read found. + (Some(path), None) if installed => { + let (text, real) = s.read_installed(path)?; + checked_real = Some(real); + text + } + (None, Some(_)) if installed => return Err(param("`installed` takes a `path`, not a `source`")), (Some(path), None) => { if path.to_ascii_lowercase().ends_with(".jsxbin") { return Err(EngineError::Other(COMPILED.to_string())); @@ -180,8 +189,17 @@ fn run(s: &mut Session, p: &Value) -> Result { }; let name = given_name.map(str::to_string).or_else(|| path.map(super::fileio::file_name)).unwrap_or_else(|| "script".to_string()); let locale = script::dollar::ui_locale(s.ui_language.as_deref().unwrap_or(&s.prefs.interface_language)); - let request = - script::Request { name, name_given: given_name.is_some(), file: path.map(str::to_string), source: text, args, fuel, locale, answers }; + let request = script::Request { + name, + name_given: given_name.is_some(), + file: path.map(str::to_string), + checked_real, + source: text, + args, + fuel, + locale, + answers, + }; let name = s.script_start(request)?; if s.script_frames { return Ok(json!({"running": true, "name": name})); @@ -273,15 +291,24 @@ impl Session { /// The text of the script at `path`, which a listing of the Scripts folder gave /// ([`script::folder::read`]), checked as `script.run` checks a file's. pub fn installed_script(&self, path: &str) -> Result { - let root = self.scripts_dir().ok_or_else(|| EngineError::Other(NOT_LISTED.to_string()))?; + self.read_installed(path).map(|(text, _)| text) + } + + /// [`Session::installed_script`] with the script's real path: the Scripts folder's, looked up + /// before the read, followed by the names under it that the read found to be no links. + fn read_installed(&self, path: &str) -> Result<(String, std::path::PathBuf)> { + let not_listed = || EngineError::Other(NOT_LISTED.to_string()); + let root = self.scripts_dir().ok_or_else(not_listed)?; crate::file_access::check_read(path).map_err(EngineError::Other)?; + let path = std::path::Path::new(path); + let mut real = std::fs::canonicalize(root).map_err(|_| not_listed())?; + real.extend(path.strip_prefix(root).map_err(|_| not_listed())?.components()); // One byte more than a script may have shows that the file is too large. - let bytes = script::folder::read(root, std::path::Path::new(path), script::MAX_SOURCE_BYTES + 1) - .ok_or_else(|| EngineError::Other(NOT_LISTED.to_string()))?; + let bytes = script::folder::read(root, path, script::MAX_SOURCE_BYTES + 1).ok_or_else(not_listed)?; if bytes.len() > script::MAX_SOURCE_BYTES { return Err(EngineError::Other(TOO_LARGE.to_string())); } - script_text(&bytes) + Ok((script_text(&bytes)?, real)) } } diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 14282c5112..f268eee9c8 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -234,6 +234,8 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { let item = item_target(s, p, C)?.of_kind(s, false); let mut ids = if item == ItemTarget::Top { own_stroke_or_leaves(s, p)? } else { item.targets(s, p)? }; let new_art = no_targets(&ids, p); + // The art of a graph design in a series keeps the design's stroke. + ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); if item == ItemTarget::Top { // Images and symbol instances take no stroke from the panel. let d = &s.doc()?.doc; @@ -368,6 +370,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result { } return Ok(json!({"objects": 0})); } + let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); let label = if up { "Stroke Weight Up" } else { "Stroke Weight Down" }; edit_items(s, &ids, item, c, label, false, |n, index| { if index.is_none() @@ -510,6 +513,9 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { } let item = item_target(s, p, C)?.of_kind(s, false); let ids = item.targets(s, p)?; + let new_art = no_targets(&ids, p); + // The art of a graph design in a series keeps the design's stroke. + let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); let arrows_and_profile = |st: &mut StrokeLayer| { if let Some(sc) = scale { st.arrow_scale = sc; @@ -523,7 +529,7 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { } }; // As stroke.set: with nothing selected, for the next object drawn. - if no_targets(&ids, p) + if new_art && (scale.is_some() || swap || flip.is_some()) && let Some(st) = s.new_art_stroke_mut() { diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index e58f109d20..86019048f8 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. 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 (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. 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 (with `add`: of the kinds of row the style shows) and, without `add`, 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 the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -332,7 +332,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { let i = style_index(&s.doc()?.doc, name)?; let add = bool_or(p, "add", false); let chars = s.prefs.override_char_color; - let own = super::appearance::edit_targets(s, p)?; + let own = super::appearance::edit_targets(s, p, "graphicStyle.apply")?; let ids = super::appearance::rowless_series_marks(&s.doc()?.doc, &own); // The series groups their paths stand for: a style that replaces the appearance clears what they hold themselves, // and a style added on top unlinks them. @@ -521,7 +521,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { } fn style_break_link(s: &mut Session, p: &Value) -> Result { - let ids = super::appearance::whole_targets(s, p)?; + let ids = super::appearance::whole_targets(s, p, "graphicStyle.breakLink")?; s.edit("Break Link to Graphic Style", |d, _| { set_link(d, &ids, None); Ok(()) diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index bc06f197e5..88288d6486 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -167,7 +167,7 @@ fn add(s: &mut Session, p: &Value) -> Result { } if apply { // Objects that can't take a style are refused before any style is added. - super::appearance::edit_targets(s, &if ids_given { json!({"ids": p["ids"]}) } else { json!({}) })?; + super::appearance::edit_targets(s, &if ids_given { json!({"ids": p["ids"]}) } else { json!({}) }, C)?; } let mut names = names_param(p, C)?; names.extend(text_param(p, "name", C)?.map(str::to_string)); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 7aed215d3d..769ca7e6c5 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; \"hkna\" is H or V Style, kana in the font's forms for the writing direction, shaped as `vkna` in vertical type, and \"ital\" is Roman Italics, half-width alphanumerics in the font's italic forms, both on or off only; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options: the share of the blank space on each side of each character's ink, inside its own width, that is taken away in horizontal type, the glyph keeping its shape; 100 sets the ink edge to edge; a character without ink, such as a space, keeps its width; a ligature or a letter with its marks takes the value of its first character; tracking, kerning and Insert Aki are added after it, and justification keeps it; vertical type keeps the characters' spacing; 0 by default; a value that is not a number or is outside the range is refused), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options: the characters are drawn in that form of the font, the OpenType feature trad, expt, jp04, jp90, jp78, jp83, fwid, hwid, twid or qwid, one form at a time; a character the font has no such glyph for keeps its own; \"default\", Revert To Default Forms, draws the font's default glyphs; another value is refused), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; \"hkna\" is H or V Style, kana in the font's forms for the writing direction, shaped as `vkna` in vertical type, and \"ital\" is Roman Italics, half-width alphanumerics in the font's italic forms, both on or off only; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options: the share of the blank space on each side of each character's ink, inside its own width, that is taken away in horizontal type, the glyph keeping its shape; 100 sets the ink edge to edge; a character without ink, such as a space, keeps its width; a ligature or a letter with its marks takes the value of its first character; tracking, kerning and Insert Aki are added after it, and justification keeps it; vertical type keeps the characters' spacing; 0 by default; a value that is not a number or is outside the range is refused), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options: the characters are drawn in that form of the font, the OpenType feature trad, expt, jp04, jp90, jp78, jp83, fwid, hwid, twid or qwid, one form at a time; a character the font has no such glyph for keeps its own; \"default\", Revert To Default Forms, draws the font's default glyphs; a form other than \"default\" takes away the characters' glyph choice (text.setGlyphAlternate); with fullWidth, halfWidth, thirdWidth or quarterWidth, proportionalMetrics is kept but `palt` is not applied, and the font's pair kerning is turned off; another value is refused), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -49,7 +49,7 @@ pub fn specs() -> Vec { "Touch Type", [], None, - "{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright)} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)", + "{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright)} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width. An error for a character of type on an open path that is not drawn because it falls before the path's start → {id, start, end} (the character's bytes)", has_doc, touch_type ), @@ -1088,7 +1088,7 @@ impl CharChange { st.digits = v; } if let Some(v) = self.glyph_form { - st.glyph_form = v; + st.set_glyph_form(v); } if let Some(v) = self.font_height_reference { st.font_height_reference = v; @@ -1550,8 +1550,21 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { change.localize(&t, C)?; let points = t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - let g = lay.glyphs.iter().find(|g| g.byte <= start && start < g.byte + g.len).ok_or_else(|| bad(C, format!("no character at byte {start}")))?; + let holds = |g: &&vectorcraft_text::PositionedGlyph| g.byte <= start && start < g.byte + g.len; + lay.glyphs.iter().find(holds).ok_or_else(|| bad(C, format!("no character at byte {start}")))?; + // A character before an open path's start is not drawn; one whose glyphs straddle the start + // is changed by its first drawn glyph. + let g = lay + .glyphs + .iter() + .filter(holds) + .find(|g| !g.off_path) + .ok_or_else(|| bad(C, format!("the character at byte {start} is not drawn: it falls before its path's start")))?; let (a, b) = (g.byte, g.byte + g.len); + // The same glyph of the character in another layout of the type: its place among the glyphs + // of the character. + let nth = lay.glyphs.iter().filter(|h| h.byte == a && h.len > 0).position(|h| std::ptr::eq(h, g)).unwrap_or(0); + let same = |l: &vectorcraft_text::TextLayout| l.glyphs.iter().filter(|h| h.byte == a && h.len > 0).nth(nth).cloned(); // The line's direction in reading order. let d = Vec2::new(g.angle.cos(), g.angle.sin()); let along = if g.rtl { -d } else { d }; @@ -1623,7 +1636,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { // rises, which would make its horizontal drift count as baseline movement. touch(&mut trial, pr.map(|_| 0.0), Some(0.0), to - shift); let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial); - l.glyphs.iter().find(|h| h.byte == a && h.len > 0).map(|h| h.xf * Point::ZERO) + same(&l).map(|h| h.xf * Point::ZERO) }; let (per_step, across) = match (pen_at(from + step), pen_at(from + 2.0 * step)) { (Some(p), Some(q)) => (linear(t.xf, q - p).hypot() / step.abs(), (q - p).dot(up) / step), @@ -1653,7 +1666,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { touch(&mut trial, None, None, 0.0); let l = vectorcraft_text::layout(db, &trial); let intrinsic = |h: &vectorcraft_text::PositionedGlyph| h.xf * Point::ZERO - h.xf * h.pivot; - l.glyphs.iter().find(|h| h.byte == a && h.len > 0).map_or(Vec2::ZERO, |h| intrinsic(h) - intrinsic(g)) + same(&l).map_or(Vec2::ZERO, |h| intrinsic(&h) - intrinsic(g)) } else { Vec2::ZERO }; @@ -1681,7 +1694,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { let mut trial = t.clone(); touch(&mut trial, k.0, k.1, raise); let l = vectorcraft_text::layout(db, &trial); - let h = l.glyphs.iter().find(|h| h.byte == a && h.len > 0).ok_or_else(|| bad(C, "the character's size can't be measured"))?; + let h = same(&l).ok_or_else(|| bad(C, "the character's size can't be measured"))?; let across_goal = (g.xf * Point::ZERO).to_vec2().dot(up) + dy / doc_len(up) + intrinsic_delta.dot(up); raise += (across_goal - (h.xf * Point::ZERO).to_vec2().dot(up)) / baseline_step; if !raise.is_finite() { @@ -1695,7 +1708,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { let mut trial = t.clone(); touch(&mut trial, k.0, k.1, raise); let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial); - let h = l.glyphs.iter().find(|h| h.byte == a && h.len > 0)?; + let h = same(&l)?; let next = match (l.glyphs.iter().find(|n| n.byte == b && n.len > 0 && n.line == h.line), next_goal) { (Some(n), Some(at)) => pen(n) - at, _ => 0.0, @@ -1704,7 +1717,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { (Some(n), Some(at)) => pen(n) - at, _ => 0.0, }; - Some([pen(h) - goal, next, previous]) + Some([pen(&h) - goal, next, previous]) }; let placed = |m: [f64; 3]| m.iter().all(|v| v.abs() <= 1e-9); // A manual kerning value turns off the font's kerning for its character, each style run is diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index e0e5d8a926..a63ebff9c4 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -2008,6 +2008,38 @@ fn glyph_form_is_one_character_attribute_saved_only_when_set() { assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::Default)); } +/// A glyph choice (`text.setGlyphAlternate`) would keep drawing its own glyph over a glyph form, +/// so setting a form other than "default" takes it away, in `text.setRangeStyle` and in +/// `text.setFormat`; "default" (Revert To Default Forms) and calls without `glyphForm` keep it. +#[test] +fn a_glyph_form_takes_away_the_characters_glyph_choice() { + use vectorcraft_doc::{GlyphChoice, GlyphForm}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "亜唖"})).unwrap()["id"].as_u64().unwrap(); + let choice = GlyphChoice { ch: '唖', gid: 42, font: "Test-Regular".into(), version: "1".into() }; + if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).map(|n| &mut n.kind) { + vectorcraft_text::edit::style_range(&mut t.runs, 3, 6, |st| st.glyph_alternate = Some(choice.clone())); + } + let state = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.glyph_form, r.style.glyph_alternate.is_some())).collect::>(), + _ => panic!("text"), + }; + s.execute("text.setRangeStyle", &json!({"id": id, "size": 30})).unwrap(); + assert_eq!(state(&s), [("亜".into(), GlyphForm::Default, false), ("唖".into(), GlyphForm::Default, true)], "no glyphForm: kept"); + s.execute("text.setRangeStyle", &json!({"id": id, "glyphForm": "default"})).unwrap(); + assert_eq!(state(&s)[1], ("唖".into(), GlyphForm::Default, true), "default: kept"); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "glyphForm": "jis78"})).unwrap(); + assert_eq!(state(&s), [("亜".into(), GlyphForm::Default, false), ("唖".into(), GlyphForm::Jis78, false)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(state(&s)[1], ("唖".into(), GlyphForm::Default, true), "one undo step brings both back"); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"glyphForm": "default"})).unwrap(); + assert!(state(&s).iter().any(|r| r.2), "default: kept"); + s.execute("text.setFormat", &json!({"glyphForm": "traditional"})).unwrap(); + assert!(state(&s).iter().all(|r| r.1 == GlyphForm::Traditional && !r.2), "{:?}", state(&s)); +} + /// No Break is a character attribute, saved only when on. #[test] fn no_break_is_a_character_attribute_saved_only_when_on() { diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index d793fc12a3..01615e5f4f 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Type on a Path Options…", ["Type", "Type on a Path"], None, - "{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}", + "{ids?|id?, start?: -1000000..1 (below 0 only on an open path), end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path); on an open path a start below 0 starts the text that far before the path's start, and the characters whose middle falls before it are not drawn (on a closed path it is the path's start), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}", has_selection, path_options ), @@ -1018,7 +1018,18 @@ fn path_options(s: &mut Session, p: &Value) -> Result { Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| Some(x.clamp(0.0, 1.0))).ok_or_else(|| bad(C, format!("`{k}` is a number 0..1"))), } }; - let start = fraction("start")?; + // On an open path the text may start before the path's start (below 0, see + // `TextKind::path_text_start`); on a closed path a start below 0 is the path's start. + let start = match p.get("start") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_f64() + .filter(|x| x.is_finite()) + .map(|x| x.clamp(-vectorcraft_doc::PATH_LEAD_MAX, 1.0)) + .ok_or_else(|| bad(C, "`start` is a number up to 1 (below 0: before an open path's start)"))?, + ), + }; + let start_on = |path: &vectorcraft_geom::PathData, v: f64| if path.is_closed() { v.max(0.0) } else { v }; // `end: null` puts the end bracket back at the end of the path. let end = p.get("end").map(|_| fraction("end")).transpose()?; let flip = switch_or(p, "flip", false, C)?; @@ -1037,8 +1048,8 @@ fn path_options(s: &mut Session, p: &Value) -> Result { // Without a flip, when every object already has every value given, the document and its undo // history stay as they are. let has_values = |t: &TextObject| match &t.kind { - TextKind::OnPath { start: st, end: en, .. } => { - start.is_none_or(|v| v == *st) + TextKind::OnPath { path, start: st, end: en } => { + start.is_none_or(|v| start_on(path, v) == *st) && end.is_none_or(|v| v == *en) && effect.is_none_or(|e| t.path_effect == e) && align.is_none_or(|a| t.path_align == a) @@ -1053,9 +1064,9 @@ fn path_options(s: &mut Session, p: &Value) -> Result { s.edit("Type on a Path Options", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; - let TextKind::OnPath { start: st, end: en, .. } = &mut t.kind else { continue }; + let TextKind::OnPath { path, start: st, end: en } = &mut t.kind else { continue }; if let Some(v) = start { - *st = v; + *st = start_on(path, v); } if let Some(v) = end { *en = v; diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 9f676e737c..3c203b64ee 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -399,6 +399,12 @@ impl ScriptScope { Self { roots: std::fs::canonicalize(dir).ok().into_iter().collect() } } + /// The scope of a script run from a file in the folder whose real path, with its links already + /// followed, is `real`: that folder, which is not looked up again. + pub(crate) fn of_real_folder(real: PathBuf) -> Self { + Self { roots: vec![real] } + } + /// Is `path` refused by its name alone: a `..` component, a NUL, a Windows device name or /// stream, or a computer on the network? fn refused_by_name(path: &Path) -> bool { diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index f1928228df..4223d829ba 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -295,12 +295,14 @@ fn display(path: &Path) -> Arc { impl Files { /// The files of a run of the script at `path` (`None` for a script given as text), with the - /// `JSINCLUDE` folders `jsinclude`. - pub(crate) fn new(path: Option<&str>, jsinclude: Vec) -> Self { - let real = path.and_then(|p| std::fs::canonicalize(p).ok()); + /// `JSINCLUDE` folders `jsinclude`. `checked` is the script's real path when the read of its text + /// found it, which the run's folder then comes from; without it, `path` is looked up. + pub(crate) fn new(path: Option<&str>, checked: Option, jsinclude: Vec) -> Self { + let scope_of_checked = checked.as_deref().and_then(Path::parent).map(|d| ScriptScope::of_real_folder(d.to_path_buf())); + let real = checked.or_else(|| path.and_then(|p| std::fs::canonicalize(p).ok())); let dir = real.as_deref().and_then(folder_of); Self { - scope: dir.as_deref().map(ScriptScope::of_folder).unwrap_or_default(), + scope: scope_of_checked.or_else(|| dir.as_deref().map(ScriptScope::of_folder)).unwrap_or_default(), jsinclude, include_path: Vec::new(), files: 0, diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 1b95b5fe2a..b52fa6e66a 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -163,6 +163,9 @@ pub(crate) struct Request { pub name_given: bool, /// The file the script was read from, which its included files are read beside. pub file: Option, + /// The real path of `file` as the read of `source` found it, which the included files are then + /// read beside instead of looking `file` up again. + pub checked_real: Option, pub source: String, pub args: Map, pub fuel: u64, @@ -1814,7 +1817,7 @@ impl Session { self.script_high_water.retain(|uid, _| open.contains(uid)); self.script_stop.clear(); self.script_report = None; - let mut files = include::Files::new(request.file.as_deref(), include::Files::jsinclude_env()); + let mut files = include::Files::new(request.file.as_deref(), request.checked_real, include::Files::jsinclude_env()); let expanded = files.expand_script(&request.source); let name = match &expanded { Ok(include::Expanded { name: Some(name), .. }) if !request.name_given => name.clone(), diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 98a577abed..4f0b739951 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -115,7 +115,8 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { "scatter-shapes", "game-trails", "energy-beams", - "dry-media" + "dry-media", + "tech-circuit" ] ); assert!(libs.iter().all(|l| l["category"] == "builtIn")); @@ -217,6 +218,87 @@ fn black_stroke() -> vectorcraft_doc::StrokeLayer { vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0) } +/// Original circuit art and repeat tiles survive adding, applying and saving a library. +#[test] +fn the_tech_circuit_library_constructs_adds_and_applies_each_brush() { + let mut s = session(); + let listed = run(&mut s, "brush.libraries", json!({})); + let entry = listed["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "tech-circuit").unwrap(); + assert_eq!((entry["name"].as_str(), entry["category"].as_str(), entry["count"].as_u64()), (Some("Tech Circuit"), Some("builtIn"), Some(3))); + let (_, lib) = cmd::brushlib::library(&s, "Tech Circuit").unwrap(); + let expected = [("Circuit Trace", "art"), ("Bus Lines", "pattern"), ("Terminal Nodes", "pattern")]; + assert_eq!(lib.brushes.iter().map(|b| (b.name.as_str(), b.kind.type_id())).collect::>(), expected); + let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 20, "x2": 210, "y2": 20}))["id"].as_u64().unwrap(); + let curve = { + let mut p = line_to(40.0); + p.curve_to((60.0, -40.0), (140.0, 40.0), (200.0, 0.0)); + p + }; + let corner = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 80.0, 60.0)).to_bezpath(); + for b in &lib.brushes { + let mut sanitized = b.clone(); + sanitized.sanitize(); + assert_eq!(&sanitized, b, "{} changes on load", b.name); + for tile in b.art() { + let pts = anchors(&[tile]); + assert!(!pts.is_empty() && pts.len() <= 100, "{}", b.name); + assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite())); + tile.walk(&mut |n| { + if let Some(p) = n.path_data() { + assert!(p.subpaths.iter().all(|s| s.closed)); + } + }); + } + if let BrushKind::Pattern(p) = &b.kind { + assert_eq!(p.colorization, vectorcraft_brush::Colorization::Tints); + assert_eq!(p.spacing, 0.0, "conductors join adjacent tiles"); + if b.name == "Bus Lines" { + let lanes = p.side.children().unwrap(); + assert_eq!(lanes.len(), 3); + let boxes: Vec<_> = lanes.iter().map(|n| n.path_data().unwrap().bounds().unwrap()).collect(); + for r in &boxes { + assert_eq!((r.x0, r.x1), (0.0, 24.0)); + } + assert!(boxes.windows(2).all(|r| r[0].y1 < r[1].y0), "lanes stay separate"); + } + if b.name == "Terminal Nodes" { + let mut rings = 0; + p.side.walk(&mut |n| { + if matches!(n.kind, NodeKind::Compound { rule: vectorcraft_geom::FillRule::EvenOdd, .. }) { + rings += 1; + } + }); + assert_eq!(rings, 1, "a hollow circular test point per tile"); + } + } + for p in [line_to(200.0), line_to(2.0), curve.clone(), corner.clone()] { + let pieces = vectorcraft_brush::stroke_pieces(&lib.doc, b, &p, &black_stroke()); + let pts = anchors(&pieces.iter().collect::>()); + assert!(!pts.is_empty() && pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{}", b.name); + } + let before = undo_len(&s); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "tech-circuit", "name": b.name, "apply": true, "ids": [path]})); + assert_eq!(strs(&r["added"]), [b.name.as_str()]); + assert_eq!(r["applied"], json!([path])); + assert_eq!(undo_len(&s), before + 1); + assert_eq!(brush(&s, &b.name), *b); + assert_eq!(doc(&s).node(NodeId(path)).unwrap().appearance.stroke().unwrap().brush.as_deref(), Some(b.name.as_str())); + run(&mut s, "edit.undo", json!({})); + assert!(vectorcraft_brush::find(doc(&s), &b.name).is_none()); + run(&mut s, "edit.redo", json!({})); + let before = undo_len(&s); + let again = run(&mut s, "brush.addFromLibrary", json!({"library": "tech-circuit", "name": b.name})); + assert!(strs(&again["added"]).is_empty()); + assert_eq!(strs(&again["existing"]), [b.name.as_str()]); + assert_eq!(undo_len(&s), before); + } + let names: Vec<&str> = expected.iter().map(|(name, _)| *name).collect(); + let saved = run(&mut s, "brush.saveLibrary", json!({"names": names})); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + let reopened = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!(vectorcraft_brush::library(&reopened), lib.brushes); +} + /// Ink and Wash: a tapered ink art brush, a brush pen whose size follows the pen pressure and two /// translucent washes, with names of their own and a few paths of art each. #[test] diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index e5e6174af0..a0129c1cd1 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -612,6 +612,100 @@ fn eraser_size_follows_the_pen_pressure() { assert!(err.to_string().contains("sizeMode"), "{err}"); } +/// Eraser Tool Options' Angle, Roundness and Size variation menus: a value on Rotation, Bearing, +/// Tilt or Pressure takes what the pen read along the drag, Random stays within its Variation, a +/// drag without the reading erases at the value itself, an open path is cut where the varied tip +/// covers it, and an unknown mode or a variation out of its range is refused with nothing erased. +#[test] +fn the_eraser_tip_varies_with_its_modes() { + // Which of `probes` a level drag through the middle of a 400 by 200 rectangle erases, each of + // its points `[x, 100]` followed by `pen` ([pressure, tiltX, tiltY, rotation]). + let erased = |tip: serde_json::Value, pen: serde_json::Value, probes: &[(f64, f64)]| -> Vec { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 400.0, 200.0); + let points: Vec<_> = (0..=20) + .map(|i| { + let mut p = vec![json!(100.0 + f64::from(i) * 10.0), json!(100.0)]; + p.extend(pen.as_array().cloned().unwrap_or_default()); + serde_json::Value::Array(p) + }) + .collect(); + s.execute("path.eraseRegion", &with(&json!({"points": points, "size": 20}), tip.clone())).unwrap(); + let filled = |x: f64, y: f64| { + paths(&s).iter().any(|(_, pd)| vectorcraft_geom::hit::fill_contains(&pd.to_bezpath(), FillRule::NonZero, Point::new(x, y))) + }; + assert!(filled(200.0, 150.0), "{tip} {pen}: the rest of the rectangle stays"); + probes.iter().map(|&(x, y)| !filled(x, y)).collect() + }; + let none = json!(null); + // A 20 by 2 pt tip lying level erases a band 2 pt tall; standing upright it erases one 20 pt tall. + let level = json!({"roundness": 10}); + let probes = [(200.0, 100.5), (200.0, 108.0)]; + assert_eq!(erased(level.clone(), json!([]), &probes), [true, false], "Fixed"); + let on_rotation = with(&level, json!({"angleMode": "rotation", "angleVariation": 90})); + // The barrel turned half way round reads the most, which turns the tip upright. + assert_eq!(erased(on_rotation.clone(), json!([none, none, none, 180]), &probes), [true, true], "Rotation"); + assert_eq!(erased(on_rotation.clone(), json!([]), &probes), [true, false], "Rotation without a reading"); + // Leaning to the left (a bearing of 180°) reads the most too. + let on_bearing = with(&level, json!({"angleMode": "bearing", "angleVariation": 90})); + assert_eq!(erased(on_bearing.clone(), json!([none, -45, 0, none]), &probes), [true, true], "Bearing"); + assert_eq!(erased(on_bearing, json!([none, 45, 0, none]), &probes), [true, false], "Bearing at 0°"); + // A round tip: upright the pen gives 28 pt, nearly flat about 12 pt. + let on_tilt = json!({"sizeMode": "tilt", "sizeVariation": 8}); + let wide = [(200.0, 105.0), (200.0, 113.0)]; + assert_eq!(erased(on_tilt.clone(), json!([none, 0, 0, none]), &wide), [true, true], "Tilt upright"); + assert_eq!(erased(on_tilt, json!([none, 89, 0, none]), &wide), [true, false], "Tilt flat"); + // Roundness from 10 % less 90 (kept to 1 %) at the lightest pressure to 100 % at the heaviest. + let on_pressure = with(&level, json!({"roundnessMode": "pressure", "roundnessVariation": 90})); + assert_eq!(erased(on_pressure.clone(), json!([1.0]), &probes), [true, true], "Pressure at the heaviest"); + // At the lightest the tip is 0.2 pt across, narrower than the 2 pt band Fixed erases. + assert_eq!(erased(on_pressure, json!([0.0]), &[(200.0, 100.0), (200.0, 100.5)]), [true, false], "Pressure at the lightest"); + // Random: one size for the drag, from 16 to 24 pt. + let random = json!({"sizeMode": "random", "sizeVariation": 4}); + assert_eq!(erased(random, json!([]), &[(200.0, 107.5), (200.0, 112.5)]), [true, false], "Random"); + // A press on a line with the tip turned upright by its rotation cuts 5 pt of it, not 20. + let left_of_line = |pen: serde_json::Value| { + let mut s = session(); + line(&mut s, 300.0, 150.0, 500.0, 150.0); + let mut point = vec![json!(400), json!(150)]; + point.extend(pen.as_array().cloned().unwrap_or_default()); + let tip = json!({"points": [point], "size": 20, "roundness": 25, "angleMode": "rotation", "angleVariation": 90}); + s.execute("path.eraseRegion", &tip).unwrap(); + paths(&s).iter().map(|p| p.1.length()).sum::() + }; + let upright = left_of_line(json!([none, none, none, 180])); + assert!((upright - 195.0).abs() < 0.3, "the upright tip left {upright} pt of the line"); + let level_tip = left_of_line(json!([])); + assert!((level_tip - 180.0).abs() < 0.3, "the level tip left {level_tip} pt of the line"); + // A 1500 pt tip on Tilt erases 1500 pt across with a mouse, as it does on Fixed. + let mut s = session(); + rect(&mut s, 0.0, 0.0, 4000.0, 4000.0); + let big = json!({"points": [[1000, 2000], [3000, 2000]], "size": 1500, "sizeMode": "tilt", "sizeVariation": 10}); + s.execute("path.eraseRegion", &big).unwrap(); + let filled = + |x: f64, y: f64| paths(&s).iter().any(|(_, pd)| vectorcraft_geom::hit::fill_contains(&pd.to_bezpath(), FillRule::NonZero, Point::new(x, y))); + assert_eq!((filled(2000.0, 2740.0), filled(2000.0, 2760.0)), (false, true), "a 1500 pt tip"); + // A Size Variation kept while Size is Fixed leaves the size alone. + let kept = with(&on_rotation, json!({"sizeMode": "fixed", "sizeVariation": 1000000})); + assert_eq!(erased(kept, json!([]), &probes), [true, false], "Size on Fixed with a Variation"); + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let stroke = json!({"points": [[-10, 50], [110, 50]]}); + let modes = "\"fixed\", \"random\", \"pressure\", \"tilt\", \"bearing\" or \"rotation\""; + for (extra, want) in [ + (json!({"angleMode": "wheel"}), format!("angleMode is one of {modes}, not `wheel`")), + (json!({"roundnessMode": 3}), format!("roundnessMode is one of {modes}, not 3")), + (json!({"sizeMode": "stylusWheel"}), format!("sizeMode is one of {modes}, not `stylusWheel`")), + (json!({"angleVariation": 181}), "angleVariation must be at most 180".to_string()), + (json!({"roundnessVariation": 101}), "roundnessVariation must be at most 100".to_string()), + (json!({"roundnessVariation": -1}), "roundnessVariation must be 0 or more".to_string()), + ] { + refuses_with(&mut s, "path.eraseRegion", &with(&stroke, extra), &want); + } + refuses_a_number_given_as_text(&mut s, "path.eraseRegion", &stroke, "angleVariation"); +} + /// A Blob Brush stroke merges with the topmost matching path it touches and with the touched /// matching paths next to it in stacking order in the same group or layer; matching paths in other /// layers or groups, or below an object of another kind, are left alone. diff --git a/crates/engine/src/tests/pathtype.rs b/crates/engine/src/tests/pathtype.rs index 8aa8c05684..5ceba16c10 100644 --- a/crates/engine/src/tests/pathtype.rs +++ b/crates/engine/src/tests/pathtype.rs @@ -127,6 +127,32 @@ fn options_keep_the_brackets() { } } +/// `start` below 0 starts type on an open path before the path's start, one undo step; on a closed +/// path it is the path's start, and a start that is not a number is an error. +#[test] +fn options_start_type_before_an_open_path() { + let mut s = session(); + let id = on_line(&mut s); + let n = undo_len(&s); + let r = s.execute("type.pathOptions", &json!({"start": -0.25})).unwrap(); + assert_eq!(brackets(&s, id), (-0.25, None)); + assert!(near(r["start"].as_f64().unwrap(), -0.25), "{r}"); + assert_eq!(undo_len(&s), n + 1); + // The same start again records nothing. + s.execute("type.pathOptions", &json!({"start": -0.25})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + // 100 pt before a 400 pt line: the 12 pt word is drawn nowhere. + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)); + assert!(!laid.glyphs.is_empty() && laid.glyphs.iter().all(|g| g.outline.elements().is_empty())); + assert!(s.execute("type.pathOptions", &json!({"start": "-0.5"})).is_err()); + // A flip keeps the stretch from the path's start: the type then starts at the start bracket. + s.execute("type.pathOptions", &json!({"flip": true})).unwrap(); + assert_eq!(brackets(&s, id), (0.0, None)); + let ring = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [200.0, 90.0], "Ring"); + s.execute("type.pathOptions", &json!({"ids": [ring.0], "start": -0.25})).unwrap(); + assert_eq!(brackets(&s, ring).0, 0.0); +} + /// Type on a Path Options given the values the type has (OK in the dialog with nothing changed, the /// effect it has from the Type on a Path menu, the end bracket at the end of the path, or the /// query's own reply) records no undo step: the step to redo stays and the document stays diff --git a/crates/engine/src/tests/pattern.rs b/crates/engine/src/tests/pattern.rs index 0f29befd80..fafcc80ac6 100644 --- a/crates/engine/src/tests/pattern.rs +++ b/crates/engine/src/tests/pattern.rs @@ -492,6 +492,111 @@ fn options_set_tile_type_spacing_and_overlap() { assert!(s.execute("pattern.options", &json!({"name": "Dots", "width": -1})).is_err()); } +/// Move Tile with Art: in pattern editing mode, a move of all of the tile art moves the tile by the +/// same offset, through `object.move`, `object.nudge` and `object.transform`. With the option off +/// (a new pattern's default), a move of part of the art, a rotation or a copy, the tile stays. +/// Undo puts the tile back, and Done keeps the moved tile. +#[test] +fn move_tile_with_art_moves_the_tile_when_all_of_the_art_moves() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 12.0, 0.0, 6.0, 6.0); + sel(&mut s, &[a, b]); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + let tile = |s: &Session| doc(s).pattern("P").unwrap().tile; + let start = tile(&s); + let art: Vec = s.doc().unwrap().selection.objects.to_vec(); + assert_eq!(art.len(), 2); + assert!(!doc(&s).pattern("P").unwrap().move_tile_with_art, "off for a new pattern"); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + assert_eq!(tile(&s), start, "with the option off the tile stays"); + s.execute("object.move", &json!({"dx": -5, "dy": 0})).unwrap(); + + s.execute("pattern.options", &json!({"moveTileWithArt": true})).unwrap(); + assert_eq!(s.execute("pattern.list", &json!({})).unwrap()["patterns"][0]["moveTileWithArt"], true); + s.execute("object.move", &json!({"dx": 3, "dy": 4})).unwrap(); + assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(3.0, 4.0)); + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, -1, 2]})).unwrap(); + assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(2.0, 6.0)); + let k = s.prefs.keyboard_increment; + s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); + assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(2.0 + k, 6.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(3.0, 4.0), "undo puts the tile back"); + let moved = tile(&s); + + sel(&mut s, &art[..1]); + s.execute("object.move", &json!({"dx": 7, "dy": 0})).unwrap(); + assert_eq!(tile(&s), moved, "a move of part of the art leaves the tile"); + sel(&mut s, &art); + s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + assert_eq!(tile(&s), moved, "a rotation leaves the tile"); + s.execute("object.move", &json!({"dx": 1, "dy": 1, "copy": true})).unwrap(); + assert_eq!(tile(&s), moved, "a copy leaves the tile"); + + s.execute("object.pattern.done", &json!({})).unwrap(); + assert_eq!(tile(&s), moved, "Done keeps the moved tile"); + assert!(doc(&s).pattern("P").unwrap().move_tile_with_art); +} + +/// Move Tile with Art counts a group in the tile art as moved when each of its members moved, one +/// by one selected. +#[test] +fn move_tile_with_art_counts_a_group_whose_members_all_moved() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 12.0, 0.0, 6.0, 6.0); + sel(&mut s, &[a, b]); + s.execute("object.group", &json!({})).unwrap(); + s.execute("object.pattern.make", &json!({"name": "G"})).unwrap(); + s.execute("pattern.options", &json!({"moveTileWithArt": true})).unwrap(); + let start = doc(&s).pattern("G").unwrap().tile; + let layer = doc(&s).pattern_edit.as_ref().unwrap().layer; + let group = doc(&s).node(layer).unwrap().children().unwrap()[0].clone(); + let members: Vec = group.children().unwrap().iter().map(|c| c.id).collect(); + assert_eq!(members.len(), 2); + sel(&mut s, &members[..1]); + s.execute("object.move", &json!({"dx": 4, "dy": 0})).unwrap(); + assert_eq!(doc(&s).pattern("G").unwrap().tile, start, "one member of two"); + sel(&mut s, &members); + s.execute("object.move", &json!({"dx": 0, "dy": 5})).unwrap(); + assert_eq!(doc(&s).pattern("G").unwrap().tile, start + vectorcraft_geom::Vec2::new(0.0, 5.0), "every member"); +} + +/// Move Tile with Art moves the tile as far as the art moved: when the art is aligned to the pixel +/// grid and a move snaps it to the grid, the tile moves by the snapped distance. +#[test] +fn move_tile_with_art_follows_art_that_snaps_to_the_pixel_grid() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a]); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + s.execute("pattern.options", &json!({"moveTileWithArt": true})).unwrap(); + let art = s.doc().unwrap().selection.objects[0]; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(art).unwrap().extras_mut().pixel_aligned = true; + let start = doc(&s).pattern("P").unwrap().tile; + let origin = |s: &Session| doc(s).node(art).unwrap().geometric_bounds().unwrap().origin(); + let before = origin(&s); + s.execute("object.move", &json!({"dx": 0.3, "dy": 0})).unwrap(); + let moved = origin(&s) - before; + assert!((moved.x - 0.3).abs() > 1e-6, "the art snapped to the grid: {moved:?}"); + assert_eq!(doc(&s).pattern("P").unwrap().tile, start + moved, "the tile moved as far"); +} + +/// Pattern Options that change nothing record no undo step. +#[test] +fn options_that_change_nothing_record_no_undo_step() { + let mut s = session(); + make_dots(&mut s); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + s.execute("pattern.options", &json!({"name": "Dots", "moveTileWithArt": false, "copies": 5})).unwrap(); + assert_eq!(steps(&s), before); + s.execute("pattern.options", &json!({"name": "Dots", "moveTileWithArt": true})).unwrap(); + assert_eq!(steps(&s), before + 1); +} + #[test] fn options_rename_updates_swatch_and_paints() { let mut s = session(); @@ -1625,6 +1730,7 @@ fn pattern_commands_refuse_a_param_of_the_wrong_type() { ("pattern.options", options(json!({"copies": "5"})), number("copies", json!("5"))), ("pattern.options", options(json!({"dimCopies": "50"})), number("dimCopies", json!("50"))), ("pattern.options", options(json!({"sizeTileToArt": "true"})), switch("sizeTileToArt")), + ("pattern.options", options(json!({"moveTileWithArt": "yes"})), switch("moveTileWithArt")), ("pattern.options", options(json!({"showTileEdge": 1})), switch("showTileEdge")), ("pattern.options", options(json!({"showSwatchBounds": "no"})), switch("showSwatchBounds")), ("pattern.options", options(json!({"overlap": "right"})), "`overlap` must be an object, not \"right\"".into()), diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 65808d2360..662e301caa 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -666,6 +666,26 @@ fn linked_pages_share_one_copy_of_the_file_and_open_in_the_pages_color_mode() { assert_eq!(s.doc().unwrap().doc.color_mode, vectorcraft_doc::ColorMode::Rgb, "colorMode wins"); } +#[test] +fn linked_pages_read_the_file_once_and_a_blank_page_keeps_its_crop_box() { + use vectorcraft_testkit::pdf::{PdfPage, pdf}; + let dir = Folder::new("open-pdf-link-pages-blank"); + let src = dir.file("blank.pdf"); + let art = || PdfPage::new(100.0, 50.0, "1 0 0 rg 10 10 20 20 re f"); + write(&src, &pdf(&[art(), PdfPage::new(80.0, 40.0, ""), art()], None)); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": src, "linkPages": true, "cropTo": "bounding"})).unwrap(); + let d = &s.doc().unwrap().doc; + let boards: Vec = d.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(boards.len(), 3, "{boards:?}"); + assert_eq!((boards[0].width(), boards[0].height()), (20.0, 20.0), "the art's bounds"); + assert_eq!((boards[1].width(), boards[1].height()), (80.0, 40.0), "the blank page's crop box"); + // The pages are read from the one copy every page's blob keeps: a page that read the file + // again would count here. + let blob = d.images.values().next().map(|b| b.bytes.clone()).unwrap(); + assert_eq!(vectorcraft_pdf::reads_of(&blob), 0, "the pages are read from the file read once"); +} + #[test] fn link_pages_is_refused_without_a_path_for_other_files_and_with_a_password_or_outlines() { let dir = Folder::new("open-pdf-link-pages-refused"); diff --git a/crates/engine/src/tests/script_folder.rs b/crates/engine/src/tests/script_folder.rs index 312db3e0fc..8ea0482826 100644 --- a/crates/engine/src/tests/script_folder.rs +++ b/crates/engine/src/tests/script_folder.rs @@ -300,6 +300,71 @@ fn an_installed_script_is_read_as_script_run_reads_a_file() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn an_installed_run_runs_the_text_its_own_read_checked() { + let dir = folder("installed-run"); + let root = dir.join("Scripts"); + let script = touch(&root, "draw.jsx"); + std::fs::write(&script, "#include \"lib.jsxinc\"\nvc.log(x);").unwrap(); + std::fs::write(root.join("lib.jsxinc"), "var x = 1;").unwrap(); + // A folder in the Scripts folder that is a link: script.run by path alone follows it. + let elsewhere = dir.join("elsewhere"); + std::fs::create_dir_all(&elsewhere).unwrap(); + std::fs::write(elsewhere.join("other.jsx"), "vc.log(2);").unwrap(); + link_folder(&elsewhere, &root.join("linked")); + let linked = root.join("linked").join("other.jsx").to_string_lossy().into_owned(); + let mut s = Session::new(); + s.attach_scripts_dir(root.clone()); + let path = script.to_string_lossy().into_owned(); + let r = s.execute("script.run", &json!({"path": path, "installed": true})).unwrap(); + assert_eq!(r["log"], json!([1])); + assert_eq!(s.execute("script.run", &json!({"path": linked})).unwrap()["log"], json!([2])); + // With `installed`, the run's own read is the check: a script behind a link is not run. + let e = s.execute("script.run", &json!({"path": linked, "installed": true})).unwrap_err(); + assert_eq!(e.to_string(), cmd::scripts::NOT_LISTED); + let outside = touch(&dir, "outside.js"); + let e = s.execute("script.run", &json!({"path": outside.to_string_lossy(), "installed": true})).unwrap_err(); + assert_eq!(e.to_string(), cmd::scripts::NOT_LISTED); + // `installed` is true or false, and takes a path. + assert!( + s.execute("script.run", &json!({"path": path, "installed": "yes"})).unwrap_err().to_string().contains("`installed` must be true or false") + ); + assert!(s.execute("script.run", &json!({"source": "1", "installed": true})).unwrap_err().to_string().contains("`installed` takes a `path`")); + assert_eq!(s.execute("script.run", &json!({"source": "vc.log(3);", "installed": false})).unwrap()["log"], json!([3])); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_installed_run_reads_its_includes_in_the_folder_its_read_found() { + let dir = folder("installed-scope"); + let checked = dir.join("Scripts").join("tools"); + let swapped = dir.join("swapped"); + for (folder, x) in [(&checked, 1), (&swapped, 2)] { + std::fs::create_dir_all(folder).unwrap(); + std::fs::write(folder.join("draw.jsx"), "").unwrap(); + std::fs::write(folder.join("lib.jsxinc"), format!("var x = {x};")).unwrap(); + } + // The path now leads through a link to another folder, as after a link was put in place of the + // folder once the script's text was read. + let link = dir.join("link"); + link_folder(&swapped, &link); + let mut s = Session::new(); + let request = script::Request { + name: "draw.jsx".into(), + name_given: false, + file: Some(link.join("draw.jsx").to_string_lossy().into_owned()), + checked_real: Some(std::fs::canonicalize(checked.join("draw.jsx")).unwrap()), + source: "#include \"lib.jsxinc\"\nvc.log(x);".into(), + args: Default::default(), + fuel: script::DEFAULT_FUEL, + locale: "en_US".into(), + answers: Vec::new(), + }; + s.script_start(request).unwrap(); + assert_eq!(s.script_run_to_end().unwrap()["log"], json!([1])); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn the_automation_read_root_keeps_an_agent_out_of_the_scripts_folder() { let dir = folder("roots"); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 9dc35ff99b..62587f4824 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -724,6 +724,44 @@ fn touch_type_moves_one_character_and_leaves_the_others_where_they_are() { assert!(s.execute("text.touchType", &json!({"id": id.0, "start": (1u64 << 32) + 1, "dy": 1})).is_err(), "no character there"); } +/// Touch Type does not change a character of type on an open path that is not drawn because it +/// falls before the path's start; a drawn character of the same type still changes. +#[test] +fn touch_type_refuses_a_character_before_an_open_paths_start() { + let mut s = session(); + let pid = s.execute("path.create", &json!({"d": "M100 300 L500 300"})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "Hidden", "size": 12, "at": [100, 300]})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("type.pathOptions", &json!({"start": -0.02})).unwrap(); + let steps = undo_labels(&s).len(); + assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dy": 2})).is_err(), "the H is not drawn"); + assert_eq!(undo_labels(&s).len(), steps); + s.execute("text.touchType", &json!({"id": id.0, "start": 5, "dy": 2})).unwrap(); + // An All Caps ß is two glyphs: with the first before the path's start and the second drawn, it + // still changes. + let pid = s.execute("path.create", &json!({"d": "M100 400 L500 400"})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "ße", "size": 40, "at": [100, 400]})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "allCaps": true})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let adv = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id)).glyphs[0].advance; + s.execute("type.pathOptions", &json!({"start": -1.2 * adv / 400.0})).unwrap(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id)); + assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path); + let drawn = |s: &Session| { + let t = obj(s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let g = lay.glyphs.iter().rfind(|g| g.byte == 0 && !g.off_path).unwrap(); + t.xf * (g.xf * Point::ZERO) + }; + let before = drawn(&s); + s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dy": 2})).unwrap(); + // The drawn glyph rises 2 points. + let after = drawn(&s); + assert!((after.x - before.x).abs() < 1e-6 && (after.y - (before.y - 2.0)).abs() < 1e-6, "{before:?} -> {after:?}"); +} + /// Touch Type on the first character of a list item moves that character and leaves the bullet /// where it is. The bullet stands for no character, so the first character still moves only up /// and down. diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index c3e3e3c422..7f2ca0e3cb 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -5128,6 +5128,16 @@ fn a_first_baseline_minimum_and_a_path_end_the_reader_cant_read_are_reported() { }; assert_eq!(with(on_path(Some(0.999))).0, [writer::TYPE_ATTRIBUTES]); assert!(with(on_path(None)).0.is_empty()); + // Type that starts before its open path comes back starting at the path's start. + let mut before = on_path(None); + if let TextKind::OnPath { start, .. } = &mut before.kind { + *start = -0.25; + } + let (warnings, back) = with(before); + assert_eq!(warnings, [writer::TYPE_ATTRIBUTES]); + let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") }; + let TextKind::OnPath { start, .. } = t.kind else { panic!("type on a path") }; + assert!(start.abs() < 1e-3, "{start}"); let (warnings, back) = with(on_path(Some(0.5))); assert!(warnings.is_empty(), "{warnings:?}"); let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") }; diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 2e90817b98..8503500007 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -440,7 +440,13 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { // The text document holds the Ascent first baseline by leaving it out, without a minimum. || (t.area.first_baseline == FirstBaseline::Ascent && t.area.first_baseline_min != 0.0))) || (matches!(t.kind, TextKind::OnPath { .. }) - && (t.path_effect != PathEffect::default() || t.path_align != PathAlign::default() || t.path_spacing != 0.0 || end_read_as_none(t))) + && (t.path_effect != PathEffect::default() + || t.path_align != PathAlign::default() + || t.path_spacing != 0.0 + || end_read_as_none(t) + // The writer writes type that starts before its open path starting at the + // path's start. + || t.kind.starts_before_path())) } /// Key 35 of a run in style `st`, as the reader's `Texts::style` reads it: 3 for Tate-chu-yoko, 1 diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index a45c203d75..0c73b65092 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -2,8 +2,9 @@ //! //! The editing copy of a file keeps the characters of its text objects, with their fonts, sizes, //! colours and where they sit, in one text document of its own: between `%AI11_BeginTextDocument` -//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys. A text object -//! in a layer is only a stub naming its story in it (`/StoryIndex`). +//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys (names in files +//! saved for version 11, and for keys newer than the version a file is saved for: see [`names`]). +//! A text object in a layer is only a stub naming its story in it (`/StoryIndex`). //! //! The format isn't documented. Most of what is read here was worked out from the files of the //! project's users, used locally and never committed, by comparing it with the type their pages @@ -32,6 +33,8 @@ use vectorcraft_doc::{ }; use vectorcraft_geom::{Affine, PathData, Point, Rect}; +mod names; + /// Longest story read (bytes). pub(crate) const MAX_STORY: usize = 1 << 20; /// Biggest text document read (bytes, decoded). @@ -631,7 +634,7 @@ impl Texts { if bytes.len() > MAX_BYTES { return None; } - let doc = read(&bytes)?; + let doc = names::numbered(read(&bytes)?); let fonts = doc .get("0")? .get("1")? diff --git a/crates/eps/src/import/ate/names.rs b/crates/eps/src/import/ate/names.rs new file mode 100644 index 0000000000..614be3f227 --- /dev/null +++ b/crates/eps/src/import/ate/names.rs @@ -0,0 +1,355 @@ +//! The keys of a text document written by name. +//! +//! Files saved for version 11 write every key of the text document by its name +//! (`/DocumentResources << /FontSet << /Resources [ ...`) and a typed node's kind under +//! `/StreamTag`, with the long names of the layout's strikes (`/FrameStrike`, `/LineStrike`). Later +//! versions write each key as its number (`/0 << /1 << /0 [ ...`, the kind under `/99`, `/F`, +//! `/L`), except the keys that are newer than the version the file is saved for, which they still +//! write by name (`/ParagraphDirection`, `/VersionString`). A key's number depends on the +//! dictionary it is in (`Leading` is 5 in a character style and 11 in a line), so the names are +//! read with the dictionary each one sits in, from the root down. +//! +//! A public reader of the format lists the names and numbers of the document's resources and +//! objects, the character and paragraph features, a story's model and view, a frame's data, the +//! line and glyph strikes, a font record's name, type and axes, and a color's type and values: +//! . +//! The other names here (the sets' lists, a resource and its kind, a font record's version, the +//! styles' name, parent and features, a Kinsoku set's name and data, a frame's point, curve, first +//! baseline and path options, the runs and their kerning, a strike's kind and origin, the tab stops +//! and the paints) are VectorCraft's own reading of the format, matched to the numbers the reader +//! already uses. +//! +//! Only the keys the text document's reader looks at, and the dictionaries on the way to them, are +//! given numbers; any other key keeps its name and is skipped as before. A key that the dictionary +//! also has as a number keeps the number's value. + +use std::collections::BTreeMap; + +use super::Val; + +/// The kind of a dictionary of the text document, which gives its keys' numbers. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + Root, + Resources, + Objects, + FontSet, + FontEntry, + Font, + FontRecord, + KinsokuSet, + KinsokuEntry, + Kinsoku, + CharSheetSet, + CharSheetEntry, + CharSheet, + ParaSheetSet, + ParaSheetEntry, + ParaSheet, + FrameSet, + FrameEntry, + Frame, + Bezier, + FrameData, + FirstBaseline, + PathOptions, + TextObject, + Model, + ParaRuns, + ParaRun, + ParaRunData, + CharRuns, + CharRun, + CharRunData, + KernRuns, + KernRun, + KernRunData, + View, + FrameRef, + Strike, + Transform, + CharFeatures, + ParaFeatures, + TabStops, + TabStop, + Paint, + Color, + /// A dictionary none of whose keys are read: its keys stay as they are. + Other, +} + +/// A key of a dictionary: its name, its number, and the kind of the dictionaries its value holds +/// (the value itself, or the elements of a list). +type Key = (&'static str, &'static str, Kind); + +/// The keys of a set of resources (`FontSet`, `StyleSheetSet`, ...), whose list holds `entry`. +const fn set(entry: Kind) -> [Key; 2] { + [("Resources", "0", entry), ("DisplayList", "1", Kind::Other)] +} + +const FONT_SET: [Key; 2] = set(Kind::FontEntry); +const KINSOKU_SET: [Key; 2] = set(Kind::KinsokuEntry); +const CHAR_SHEET_SET: [Key; 2] = set(Kind::CharSheetEntry); +const PARA_SHEET_SET: [Key; 2] = set(Kind::ParaSheetEntry); +const FRAME_SET: [Key; 2] = set(Kind::FrameEntry); + +/// The character features the reader looks at. +const CHAR_FEATURES: [Key; 20] = [ + ("Font", "0", Kind::Other), + ("FontSize", "1", Kind::Other), + ("AutoLeading", "4", Kind::Other), + ("Leading", "5", Kind::Other), + ("HorizontalScale", "6", Kind::Other), + ("VerticalScale", "7", Kind::Other), + ("Tracking", "8", Kind::Other), + ("BaselineShift", "9", Kind::Other), + ("FontCaps", "12", Kind::Other), + ("BaselineDirection", "35", Kind::Other), + ("LeftAki", "49", Kind::Other), + ("RightAki", "50", Kind::Other), + ("FillColor", "53", Kind::Paint), + ("StrokeColor", "54", Kind::Paint), + ("FillFlag", "56", Kind::Other), + ("StrokeFlag", "57", Kind::Other), + ("LineCap", "61", Kind::Other), + ("LineJoin", "62", Kind::Other), + ("LineWidth", "63", Kind::Other), + ("MiterLimit", "64", Kind::Other), +]; + +/// The paragraph features the reader looks at. +const PARA_FEATURES: [Key; 22] = [ + ("Justification", "0", Kind::Other), + ("FirstLineIndent", "1", Kind::Other), + ("StartIndent", "2", Kind::Other), + ("EndIndent", "3", Kind::Other), + ("SpaceBefore", "4", Kind::Other), + ("SpaceAfter", "5", Kind::Other), + ("AutoLeading", "7", Kind::Other), + ("AutoHyphenate", "9", Kind::Other), + ("HyphenatedWordSize", "10", Kind::Other), + ("PreHyphen", "11", Kind::Other), + ("PostHyphen", "12", Kind::Other), + ("ConsecutiveHyphens", "13", Kind::Other), + ("Zone", "14", Kind::Other), + ("HyphenateCapitalized", "15", Kind::Other), + ("KeepTogether", "23", Kind::Other), + ("BurasagariType", "24", Kind::Other), + ("KinsokuOrder", "25", Kind::Other), + ("Kinsoku", "27", Kind::Other), + ("TabStops", "30", Kind::TabStops), + ("DefaultTabWidth", "31", Kind::Other), + ("DefaultStyle", "32", Kind::Other), + ("ParagraphDirection", "33", Kind::Other), +]; + +/// The keys of a strike of the layout. A glyph strike has no children; its own keys start at 5. Its +/// glyph data (key 21) has no name here: the public list above does not name it, and the files +/// checked do not write it by name. +const STRIKE: [Key; 5] = [ + ("StreamTag", "99", Kind::Other), + ("Transform", "0", Kind::Transform), + ("Bounds", "1", Kind::Other), + ("ChildProcession", "5", Kind::Other), + ("Children", "6", Kind::Strike), +]; +const GLYPH_STRIKE: [Key; 3] = [("StreamTag", "99", Kind::Other), ("Transform", "0", Kind::Transform), ("Bounds", "1", Kind::Other)]; + +/// The long names of the strikes' kinds, with the short ones later versions write. +const STRIKE_KINDS: [(&str, &str); 6] = + [("PathSelectGroupCharacter", "PC"), ("FrameStrike", "F"), ("RowColStrike", "R"), ("LineStrike", "L"), ("Segment", "S"), ("GlyphStrike", "G")]; + +impl Kind { + /// The keys of a dictionary of this kind; `tag` is the kind of typed node it is, for a strike. + fn keys(self, tag: Option<&str>) -> &'static [Key] { + use Kind as K; + match self { + K::Root => &[("DocumentResources", "0", K::Resources), ("DocumentObjects", "1", K::Objects)], + K::Resources => &[ + ("FontSet", "1", K::FontSet), + ("KinsokuSet", "4", K::KinsokuSet), + ("StyleSheetSet", "5", K::CharSheetSet), + ("ParagraphSheetSet", "6", K::ParaSheetSet), + ("TextFrameSet", "8", K::FrameSet), + ], + K::Objects => &[ + ("DocumentSettings", "0", K::Other), + ("TextObjects", "1", K::TextObject), + ("OriginalNormalStyleFeatures", "2", K::CharFeatures), + ("OriginalNormalParagraphFeatures", "3", K::ParaFeatures), + ], + K::FontSet => &FONT_SET, + K::FontEntry => &[("Resource", "0", K::Font)], + K::Font => &[("StreamTag", "99", K::Other), ("Identifier", "0", K::FontRecord)], + K::FontRecord => &[("Name", "0", K::Other), ("Type", "2", K::Other), ("MMAxis", "4", K::Other), ("VersionString", "5", K::Other)], + K::KinsokuSet => &KINSOKU_SET, + K::KinsokuEntry => &[("Resource", "0", K::Kinsoku)], + K::Kinsoku => &[("Name", "0", K::Other), ("Data", "5", K::Other)], + K::CharSheetSet => &CHAR_SHEET_SET, + K::CharSheetEntry => &[("Resource", "0", K::CharSheet)], + K::CharSheet => &[("Name", "0", K::Other), ("Parent", "5", K::Other), ("Features", "6", K::CharFeatures)], + K::ParaSheetSet => &PARA_SHEET_SET, + K::ParaSheetEntry => &[("Resource", "0", K::ParaSheet)], + K::ParaSheet => &[("Name", "0", K::Other), ("Features", "5", K::ParaFeatures), ("Parent", "6", K::Other)], + K::FrameSet => &FRAME_SET, + K::FrameEntry => &[("Resource", "0", K::Frame)], + K::Frame => &[("Point", "0", K::Other), ("Bezier", "1", K::Bezier), ("Data", "2", K::FrameData)], + K::Bezier => &[("Points", "0", K::Other)], + K::FrameData => &[ + ("Type", "0", K::Other), + ("LineOrientation", "1", K::Other), + ("FrameMatrix", "2", K::Other), + ("RowCount", "3", K::Other), + ("ColumnCount", "4", K::Other), + ("TextOnPathTRange", "6", K::Other), + ("RowGutter", "7", K::Other), + ("ColumnGutter", "8", K::Other), + ("FirstBaselineAlignment", "10", K::FirstBaseline), + ("PathData", "11", K::PathOptions), + ], + K::FirstBaseline => &[("Flag", "0", K::Other), ("Min", "1", K::Other)], + K::PathOptions => &[("Flip", "0", K::Other), ("Effect", "1", K::Other), ("Alignment", "2", K::Other), ("CS3Behavior", "6", K::Other)], + K::TextObject => &[("Model", "0", K::Model), ("View", "1", K::View)], + K::Model => &[("Text", "0", K::Other), ("ParagraphRun", "5", K::ParaRuns), ("StyleRun", "6", K::CharRuns), ("KernRun", "8", K::KernRuns)], + K::ParaRuns => &[("RunArray", "0", K::ParaRun)], + K::ParaRun => &[("RunData", "0", K::ParaRunData), ("Length", "1", K::Other)], + K::ParaRunData => &[("ParagraphSheet", "0", K::ParaSheet)], + K::CharRuns => &[("RunArray", "0", K::CharRun)], + K::CharRun => &[("RunData", "0", K::CharRunData), ("Length", "1", K::Other)], + K::CharRunData => &[("StyleSheet", "0", K::CharSheet)], + K::KernRuns => &[("RunArray", "0", K::KernRun)], + K::KernRun => &[("RunData", "0", K::KernRunData), ("Length", "1", K::Other)], + K::KernRunData => &[("ManualKern", "0", K::Other)], + K::View => &[("Frames", "0", K::FrameRef), ("Strikes", "2", K::Strike)], + K::FrameRef => &[("Resource", "0", K::Other)], + K::Strike if matches!(tag, Some("GlyphStrike" | "G")) => &GLYPH_STRIKE, + K::Strike => &STRIKE, + K::Transform => &[("Origin", "0", K::Other)], + K::CharFeatures => &CHAR_FEATURES, + K::ParaFeatures => &PARA_FEATURES, + K::TabStops => &[("TabStops", "0", K::TabStop)], + K::TabStop => &[("TabAdvance", "0", K::Other), ("TabType", "1", K::Other)], + K::Paint => &[("StreamTag", "99", K::Other), ("Color", "0", K::Color), ("CAIKnownStyleID", "5", K::Other)], + K::Color => &[("Type", "0", K::Other), ("Values", "1", K::Other)], + K::Other => &[], + } + } +} + +/// The text document `doc` (its top-level keys) with the keys it writes by name given their +/// numbers, as later versions write them. +pub(super) fn numbered(doc: Val) -> Val { + value(doc, Kind::Root) +} + +/// `v`, whose dictionaries are of kind `kind`, with its keys numbered. +fn value(v: Val, kind: Kind) -> Val { + match v { + Val::Dict(d) if kind != Kind::Other => Val::Dict(dict(d, kind)), + Val::List(l) if kind != Kind::Other => Val::List(l.into_iter().map(|x| value(x, kind)).collect()), + v => v, + } +} + +/// A run's style written by name as the number `n` of a paragraph style (`kind` `ParaSheet`) or a +/// character style (`CharSheet`) of the document: a style with nothing of its own, based on that +/// one. +fn based_on(kind: Kind, n: f64) -> Option { + let parent = match kind { + Kind::ParaSheet => "6", + Kind::CharSheet => "5", + _ => return None, + }; + Some(Val::Dict(BTreeMap::from([(parent.to_string(), Val::Num(n))]))) +} + +/// The entries of a dictionary of kind `kind` with their keys numbered. +fn dict(d: BTreeMap, kind: Kind) -> BTreeMap { + let tag = match d.get("StreamTag").or_else(|| d.get("99")) { + Some(Val::Name(n)) => Some(n.clone()), + _ => None, + }; + let keys = kind.keys(tag.as_deref()); + let mut out = BTreeMap::new(); + let mut named = vec![]; + for (k, v) in d { + match keys.iter().find(|(name, number, _)| *name == k || *number == k) { + Some((name, number, child)) => { + let v = match (*number, v) { + ("99", Val::Name(n)) => Val::Name(STRIKE_KINDS.iter().find(|(long, _)| *long == n).map_or(n, |(_, short)| short.to_string())), + (_, Val::Num(n)) if k == *name => based_on(*child, n).unwrap_or(Val::Num(n)), + (_, v) => value(v, *child), + }; + if k == *name { + named.push((number.to_string(), v)); + } else { + out.insert(k, v); + } + } + None => { + out.insert(k, v); + } + } + } + for (k, v) in named { + out.entry(k).or_insert(v); + } + out +} + +#[cfg(test)] +mod tests { + use super::super::read; + use super::*; + + fn parse(s: &str) -> Val { + read(s.as_bytes()).unwrap_or_else(|| panic!("{s}")) + } + + #[test] + fn named_keys_take_the_numbers_of_the_dictionary_they_are_in() { + // `Leading` is 5 in a character style and 11 in a line; `Name` is 0 in a style and in a font + // record. + let named = parse( + "/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Serif) \ + /VersionString (Version 2) >> >> >> ] >> /StyleSheetSet << /Resources [ << /Resource << /Name (Red) /Parent 0 \ + /Features << /Leading 18.0 /FillColor << /StreamTag /SimplePaint /Color << /Type 1 /Values [ 1.0 1.0 0.0 0.0 ] >> >> >> >> >> ] >> >> \ + /DocumentObjects << /TextObjects [ << /Model << /Text (Hi) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet 1 >> /Length 2 >> ] >> \ + /StyleRun << /RunArray [ << /RunData << /StyleSheet << /Parent 0 /Features << /FontSize 9.0 >> >> >> /Length 2 >> ] >> >> \ + /View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /FrameStrike /Transform << /Origin [ 1.0 2.0 ] >> \ + /Children [ << /StreamTag /LineStrike /Leading 14.4 /Children [ << /StreamTag /GlyphStrike /Glyphs [ 1 2 ] >> ] >> ] >> ] >> >> ] >>", + ); + let numbered_form = parse( + "/0 << /1 << /0 [ << /0 << /99 /CoolTypeFont /0 << /0 (Serif) /5 (Version 2) >> >> >> ] >> /5 << /0 [ << /0 << /0 (Red) /5 0 \ + /6 << /5 18.0 /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 1.0 0.0 0.0 ] >> >> >> >> >> ] >> >> \ + /1 << /1 [ << /0 << /0 (Hi) /5 << /0 [ << /0 << /0 << /6 1 >> >> /1 2 >> ] >> \ + /6 << /0 [ << /0 << /0 << /5 0 /6 << /1 9.0 >> >> >> /1 2 >> ] >> >> \ + /1 << /0 [ << /0 0 >> ] /2 [ << /99 /F /0 << /0 [ 1.0 2.0 ] >> \ + /6 [ << /99 /L /Leading 14.4 /6 [ << /99 /G /Glyphs [ 1 2 ] >> ] >> ] >> ] >> >> ] >>", + ); + // A character style's `Leading` is its key 5; a line's and a glyph's keys the reader doesn't + // look at keep their names. + assert_eq!(numbered(named), numbered_form); + } + + #[test] + fn a_number_wins_over_the_same_key_by_name() { + let doc = numbered(parse("/1 << /3 << /33 1 /ParagraphDirection 0 /Justification 2 >> >>")); + assert_eq!(doc, parse("/1 << /3 << /33 1 /0 2 >> >>")); + } + + #[test] + fn keys_of_dictionaries_no_reader_looks_at_keep_their_names() { + let doc = numbered(parse("/DocumentObjects << /DocumentSettings << /Name (x) /Leading 3 >> /Elsewhere << /Name (y) >> >>")); + assert_eq!(doc, parse("/1 << /0 << /Name (x) /Leading 3 >> /Elsewhere << /Name (y) >> >>")); + } + + #[test] + fn a_numbered_document_stays_as_it_is() { + let s = "/0 << /1 << /0 [ << /0 << /99 /CoolTypeFont /0 << /0 (Serif) >> >> >> ] >> >> \ + /1 << /1 [ << /0 << /0 (Hi) /5 << /0 [ << /0 << /0 1 >> /1 2 >> ] >> /6 << /0 [ << /0 << /0 0 >> /1 2 >> ] >> >> \ + /1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 1.0 2.0 ] >> >> ] >> ] >> >> ] /2 << /1 12.0 >> >>"; + // A run's style written as a number under its key's number stays a number. + assert_eq!(numbered(parse(s)), parse(s)); + } +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 738f02d857..407ca72e9a 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3169,6 +3169,29 @@ fn text_document(stories: &[Story<'_>]) -> String { ) } +/// [`text_document`] with its keys written by name, as files saved for version 11 write them: a +/// typed node's kind under `/StreamTag`, the strikes' kinds by their long names. +fn named_text_document(stories: &[Story<'_>]) -> String { + let story = |(text, align, anchor, _t, start): &Story<'_>| { + let n = text.chars().count(); + format!( + "<< /Model << /Text ({text}) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet << /Name () /Features << /Justification {align} >> /Parent 0 >> >> /Length {n} >> ] >> \ + /StyleRun << /RunArray [ << /RunData << /StyleSheet << /Name () /Parent 0 /Features << /Font 0 /FontSize 14.0 /FillColor << /StreamTag /CAITextPaint /Color << /Type 2 /Values [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /Length {n} >> ] >> >> \ + /View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /PathSelectGroupCharacter /Children [ << /StreamTag /FrameStrike /Transform << /Origin [ {} {} ] >> \ + /Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /LineStrike /Children [ \ + << /StreamTag /Segment /Transform << /Origin [ {start} 0.0 ] >> /Mapping << /CharacterCount {n} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>", + anchor.0, anchor.1 + ) + }; + let frame = |(_, _, _, t, _): &Story<'_>| format!("<< /Resource << /Point [ 0 0 ] /Data << /FrameMatrix [ 1 0 0 1 {} {} ] >> >> >>", t.0, t.1); + format!( + "/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Helvetica) >> >> >> ] >> \ + /TextFrameSet << /Resources [ {} ] >> >>\n/DocumentObjects << /TextObjects [ {} ] /OriginalNormalStyleFeatures << /FontSize 12.0 >> >>\n", + stories.iter().map(frame).collect::>().join(" "), + stories.iter().map(story).collect::>().join(" ") + ) +} + /// The editing copy of a 100 × 100 file with `layers` and the text `document`; its template box is /// centred at (50, 50). fn native_with_text(layers: &str, document: &str) -> String { @@ -3319,6 +3342,8 @@ fn type_from_the_text_document_takes_the_version_its_font_record_keeps() { // A record whose name differs from the copies' in punctuation alone still picks its version. assert_eq!(version("/0 (Vertest-Ink-Regular) /5 (1.1)"), "1.1"); assert_eq!(version("/0 (Vertest-Ink-Regular)"), "1.0"); + // Files saved for versions older than the key write it by name among the numbered ones. + assert_eq!(version("/0 (VertestInk-Regular) /VersionString (1.1)"), "1.1"); } /// A story with no runs, and a run or paragraph that names no style, is set in `[Normal Character @@ -3728,6 +3753,81 @@ fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document assert!(!r.warnings.iter().any(|w| w.contains("order the page paints")), "{:?}", r.warnings); } +#[test] +fn a_text_document_with_named_keys_gives_type_off_the_page_too() { + // Files saved for version 11 write the text document's keys by name. Story 1 is off the page: + // only the text document has it, and story 0 stays in its own text object. + let stories = [("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Away\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)]; + let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &named_text_document(&stories)); + let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show"; + let r = import(&eps(page, &art)).unwrap(); + let texts = text_of(&r.document.layers[0]); + assert_eq!(texts.iter().map(|t| t.plain_text()).collect::>(), ["Hi there", "Away"], "{:?}", r.warnings); + let x = |t: &vectorcraft_doc::TextObject| t.xf.as_coeffs()[4]; + assert!((x(texts[0]) - 58.5).abs() < 1e-6 && (x(texts[1]) - 158.5).abs() < 1e-6, "{:?} {:?}", texts[0].xf, texts[1].xf); +} + +#[test] +fn a_text_document_with_named_keys_reads_as_the_same_one_with_numbers() { + // Type on a hidden layer comes only from the text document: its styles, paints, frames and + // layout read the same whichever way its keys are written. + let stories = [("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Mid\r", 2, (8191.5, 8191.5), (0.0, 0.0), -10.0)]; + let spare = |doc: &str| { + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts: Vec = text_of(&r.document.layers[1]).into_iter().cloned().collect(); + (texts, r.warnings) + }; + let (numbered, _) = spare(&text_document(&stories)); + let (named, warnings) = spare(&named_text_document(&stories)); + assert_eq!(numbered.len(), 2); + assert_eq!(named, numbered, "{warnings:?}"); + assert_eq!(named[1].para.justify, vectorcraft_doc::Justify::Center); + let magenta = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0)); + assert_eq!(named[0].runs[0].style.fill, magenta); +} + +#[test] +fn a_text_document_with_named_keys_takes_a_runs_style_by_its_number() { + // Files saved for version 11 name a run's paragraph and character styles by their numbers in the + // document's style sets, and give point type a curve of four equal points. Styles 1 center the + // paragraph and paint the type magenta; paragraph style 2 sets its direction right to left. + let magenta = "<< /StreamTag /CAITextPaint /Color << /Type 2 /Values [ 1.0 0.0 1.0 0.0 0.0 ] >> >>"; + let story = |text: &str, para: u8, chars: u8, x: f64| { + let n = text.chars().count(); + format!( + "<< /Model << /Text ({text}) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet {para} >> /Length {n} >> ] >> \ + /StyleRun << /RunArray [ << /RunData << /StyleSheet {chars} >> /Length {n} >> ] >> >> \ + /View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /PathSelectGroupCharacter /Children [ << /StreamTag /FrameStrike \ + /Transform << /Origin [ {x} 8180.0 ] >> /Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /RowColStrike /Children [ \ + << /StreamTag /LineStrike /Children [ << /StreamTag /Segment /Transform << /Origin [ 0.0 0.0 ] >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>" + ) + }; + let doc = format!( + "/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Helvetica) >> >> >> ] >> \ + /StyleSheetSet << /Resources [ << /Resource << /Name (Normal) /Features << /Font 0 /FontSize 14.0 >> >> >> \ + << /Resource << /Name (Magenta) /Parent 0 /Features << /FillColor {magenta} >> >> >> ] >> \ + /ParagraphSheetSet << /Resources [ << /Resource << /Name (Normal) /Features << /Justification 0 >> >> >> \ + << /Resource << /Name (Centered) /Parent 0 /Features << /Justification 2 >> >> >> \ + << /Resource << /Name (Leftward) /Parent 0 /Features << /ParagraphDirection 1 >> >> >> ] >> \ + /TextFrameSet << /Resources [ << /Resource << /Bezier << /Points [ 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 ] >> \ + /Data << /Type 0 /FrameMatrix [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n\ + /DocumentObjects << /TextObjects [ {} {} ] /OriginalNormalStyleFeatures << /FontSize 12.0 >> >>\n", + story("Mid\r", 1, 1, 8200.0), + story("Back\r", 2, 0, 8300.0) + ); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.iter().map(|t| t.plain_text()).collect::>(), ["Mid", "Back"], "{:?}", r.warnings); + assert_eq!(texts[0].para.justify, vectorcraft_doc::Justify::Center); + let magenta = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0)); + assert_eq!(texts[0].runs[0].style.fill, magenta); + assert_eq!(texts[1].para.direction, Some(vectorcraft_doc::ParaDirection::RightToLeft)); + assert_eq!(texts[1].para.justify, vectorcraft_doc::Justify::Right); + assert_ne!(texts[1].runs[0].style.fill, magenta); +} + #[test] fn a_text_object_written_as_comments_is_read_like_any_other() { let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index d971e5249e..a1c40ca28f 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -137,7 +137,11 @@ pub(super) fn lost_attributes(t: &TextObject) -> bool { || !t.fractional_widths || (matches!(t.kind, TextKind::Area { .. }) && t.area != AreaOptions::default()) || (matches!(t.kind, TextKind::OnPath { .. }) - && (t.path_effect != PathEffect::default() || t.path_align != PathAlign::default() || t.path_spacing != 0.0 || end_bracket(t))) + && (t.path_effect != PathEffect::default() + || t.path_align != PathAlign::default() + || t.path_spacing != 0.0 + || end_bracket(t) + || t.kind.starts_before_path())) || several_paths(&t.kind) } diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 056db02074..e07f119215 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1560,6 +1560,22 @@ fn type_on_a_path_alignment_and_end_bracket_are_warned_as_left_out() { assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(true), start: 0.7, end: Some(0.5) })); // An open path's end bracket before its start bracket. assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: 0.5, end: Some(0.3) })); + // Type that starts before its open path: the text path holds a start on the path only. + assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: -0.25, end: None })); + assert!(!warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: 0.0, end: None })); + // It is written starting at the path's start: the same text object as a start of 0. + let written = |start: f64| { + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Along", style(12.0, Paint::solid(Color::BLACK))); + t.kind = TextKind::OnPath { path: along(false), start, end: None }; + t.xf = Affine::IDENTITY; + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + write(&doc).bytes + }; + assert_eq!(written(-0.25), written(0.0)); + assert_ne!(written(0.25), written(0.0)); let two = |first_closed: bool| { let mut path = along(first_closed); let pts = [(150.0, 100.0).into(), (250.0, 100.0).into()]; diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index d5911580aa..83d20fc49d 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -800,7 +800,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Cmd+Shift+F6", "{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view", ), - ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), + ("tool.select", "Select Tool", "", "{tool: id} (see tools) → {tool}; a tool that cannot be chosen returns the error"), ( "tool.setOption", "Tool Option", @@ -811,7 +811,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.toggleTouchMode", "Switch Selection and Drawing Modes", "", - "{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)", + "{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}; a tool that cannot be chosen returns the error and leaves the drawing tool as it was. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)", ), ( "effect.dialog", @@ -830,7 +830,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.cropImage", "Crop Image", "", - "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box", + "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box. → {tool}; a tool that cannot be chosen returns the error", ), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), ( @@ -1737,7 +1737,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.runInstalledScript", "Run Script", "", - "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (the script is checked again, then script.run runs it by its path, so its #include lines and $.evalFile read beside it), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", + "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (script.run with installed reads the file once, checks it again and runs that text, so its #include lines and $.evalFile read in the folder that read found), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", ), ( "ui.checkScript", diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index af686e1857..04a3a811f3 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -139,6 +139,7 @@ impl Exporter<'_> { let doc = self.doc; let out = self.out.clone(); let profiles = !out.standard.cmyk_only(); + let to_srgb = out.image_srgb_intent().map(|intent| crate::images::ToSrgb { intent, sixteen_bit: self.sixteen_bit }); let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| { let cmyk_image = blob.is_cmyk(); if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { @@ -148,12 +149,14 @@ impl Exporter<'_> { let icc = out.cmyk_image_icc(blob); crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None, icc.as_deref()) } else { - crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles) + crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles, to_srgb) }; - if let Some(w) = r.warning { + let img = r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles)); + // An image left out gets the warning below instead. + for w in [r.warning, r.note].into_iter().flatten().filter(|_| img.is_some()) { self.warn(w); } - r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles)) + img }); if img.is_none() { self.warn(UNDECODED); diff --git a/crates/pdf/src/export/text.rs b/crates/pdf/src/export/text.rs index c9ef8203c7..c6946f5416 100644 --- a/crates/pdf/src/export/text.rs +++ b/crates/pdf/src/export/text.rs @@ -246,7 +246,8 @@ impl Exporter<'_> { // Spaces are written too (text extraction needs them); control characters and soft // hyphens draw nothing. let blank = g.outline.elements().is_empty() && !coloured; - if blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control()) { + // Characters before the start of an open path are not drawn, spaces included. + if g.off_path || (blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control())) { continue; } // The glyph at the font size of its vertical scale: `frame` maps that size's glyph diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 5204e4f396..6806aab756 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -6,6 +6,13 @@ //! monochrome (greyscale that is only black and white). An image already as asked (not resampled, //! a JPEG kept as JPEG or another kept lossless) is embedded as it is; the others are decoded, //! resampled and encoded again. JPEG needs opaque pixels: images with transparency stay lossless. +//! A gray or RGB image that carries a profile the Output settings convert from ([`ToSrgb`]) is +//! always decoded: its pixels are converted to sRGB and written without the profile, at 16 bits a +//! sample when it has them and is otherwise kept as it is; a JPEG kept as JPEG whose samples the +//! conversion leaves unchanged (an sRGB profile) keeps its JPEG data. A black-and-white image whose +//! converted levels are grey is compressed as greyscale. A colour image the Output settings also +//! convert to their destination ([`Pixels`]) goes from its profile to sRGB, then to the +//! destination; a grey one keeps its profile there, as grey pixels are not converted. //! //! CMYK images ([`ImageBlob::cmyk`]) stay CMYK: a CMYK JPEG already as asked is embedded as it is //! when its numbers are kept; the others' ink amounts are resampled, converted when the Output @@ -22,6 +29,7 @@ use image::imageops::FilterType; use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageReader}; use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; +use vectorcraft_color::cms::{IccProfile, Intent}; use vectorcraft_doc::ImageBlob; use vectorcraft_doc::cmyk::{Inks, jpeg_components}; use vectorcraft_doc::resample::{Resample, premultiply, resized, unpremultiply}; @@ -306,6 +314,100 @@ pub(crate) struct Recoded { pub image: Option, /// Set when the image isn't compressed as asked. pub warning: Option<&'static str>, + /// Set when the image's colors are converted to the destination without its ICC profile, + /// which can't be read. + pub note: Option<&'static str>, +} + +/// How [`recode`] converts a gray or RGB image that carries an ICC profile to sRGB (Convert to +/// Destination, [`crate::output::ColorOut::image_srgb_intent`]): the destination when it is sRGB, +/// else the step before the image's [`Pixels`] conversion. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ToSrgb { + /// The rendering intent of the conversion. + pub intent: Intent, + /// 16-bit samples may be written (PDF 1.5 and later). + pub sixteen_bit: bool, +} + +/// The ICC profile image `bytes` carries ([`source_icc`]), when it is a well-formed profile of +/// colour space `space` (`GRAY` or `RGB `, [`valid_icc`]): `None` without one, `Some(None)` when +/// the CMS can't read it. +fn source_profile(bytes: &[u8], space: [u8; 4]) -> Option> { + let icc = source_icc(bytes).filter(|icc| valid_icc(icc, space))?; + Some(IccProfile::from_bytes(None, &icc).ok()) +} + +const UNCONVERTED: &str = "an image's ICC profile couldn't be read: the image keeps it and is not converted to sRGB"; +const CONVERTED_AS_SRGB: &str = "an image's ICC profile couldn't be read: its colors are converted to the destination as sRGB"; + +const UNDECODED: &str = "an image couldn't be decoded and is not converted to sRGB"; + +/// `img` converted from ICC profile `p` to sRGB with `intent`: at 16 bits a sample when it has +/// more than 8, else at 8, with its alpha; and whether any sample changed. A grey image stays grey, each level as the sRGB grey it converts to. `None` when the +/// pixels can't be converted. +fn into_srgb(img: DynamicImage, p: &IccProfile, intent: Intent) -> Option<(DynamicImage, bool)> { + let kind = img.color(); + let deep = kind.bits_per_pixel() > 8 * u16::from(kind.channel_count()); + let to8 = |v: f32| v.round() as u8; + let to16 = |v: f32| v.round() as u16; + Some(match (kind.has_color(), deep) { + (true, true) => { + let mut s = img.into_rgba16(); + let changed = convert_samples(&mut s, 3, p, intent, 65_535.0, to16)?; + (DynamicImage::ImageRgba16(s), changed) + } + (true, false) => { + let mut s = img.into_rgba8(); + let changed = convert_samples(&mut s, 3, p, intent, 255.0, to8)?; + (DynamicImage::ImageRgba8(s), changed) + } + (false, true) => { + let mut s = img.into_luma_alpha16(); + let changed = convert_samples(&mut s, 1, p, intent, 65_535.0, to16)?; + (DynamicImage::ImageLumaA16(s), changed) + } + (false, false) => { + let mut s = img.into_luma_alpha8(); + let changed = convert_samples(&mut s, 1, p, intent, 255.0, to8)?; + (DynamicImage::ImageLumaA8(s), changed) + } + }) +} + +/// Convert the `n` colour samples (1: grey, 3: RGB) of each pixel of `samples` (followed by an +/// alpha sample each, which is left as it is) from ICC profile `p` to sRGB with `intent`, a run of +/// pixels at a time; `max` is a sample's largest value, `back` turns a value of 0 to `max` into a +/// sample. Grey pixels take the mean of the sRGB values. Whether any sample changed; `None` when +/// the transform fails. +fn convert_samples>( + samples: &mut [T], + n: usize, + p: &IccProfile, + intent: Intent, + max: f32, + back: impl Fn(f32) -> T, +) -> Option { + const RUN: usize = 4096; + let mut src = Vec::with_capacity(RUN * n); + let mut dst = vec![0.0f32; RUN * 3]; + let mut changed = false; + for run in samples.chunks_mut(RUN * (n + 1)) { + src.clear(); + src.extend(run.chunks_exact(n + 1).flat_map(|px| px.iter().take(n).map(|v| (*v).into() / max))); + let out = dst.get_mut(..src.len() / n * 3)?; + p.to_srgb_pixels(&src, out, intent)?; + for (px, rgb) in run.chunks_exact_mut(n + 1).zip(out.as_chunks::<3>().0) { + let grey = [rgb.iter().sum::() / 3.0]; + let values: &[f32] = if n == 1 { &grey } else { rgb }; + for (v, c) in px.iter_mut().zip(values) { + let new = back(c * max); + changed |= new != *v; + *v = new; + } + } + } + Some(changed) } /// Is `img` (a grey image) only black and white? @@ -372,8 +474,11 @@ fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> Dynamic /// Encoded image `bytes` placed `size` points wide and high, as the Compression settings `c` ask: /// resampled when its resolution is above the threshold, compressed with ZIP or JPEG, its colours /// converted by `convert` (colour images). Pixels that keep their colours keep the ICC profile -/// they carry when `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces). -/// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is. +/// they carry when `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces); +/// with `to_srgb`, pixels in a gray or RGB profile other than sRGB are converted from it to sRGB +/// instead ([`ToSrgb`]), and colour ones then by `convert`; grey ones that `convert` leaves as +/// they are keep their profile. `interpolate` lets viewers smooth it. An image that can't be +/// decoded is kept as it is, with a warning when its pixels were to be converted to sRGB. pub(crate) fn recode( bytes: &[u8], size: (f64, f64), @@ -381,39 +486,94 @@ pub(crate) fn recode( interpolate: bool, convert: Option<&Pixels>, profiles: bool, + to_srgb: Option, ) -> Recoded { - let keep = Recoded { image: None, warning: None }; + let keep = Recoded { image: None, warning: None, note: None }; let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep }; let jpeg = reader.format() == Some(ImageFormat::Jpeg); let Ok(mut decoder) = reader.into_decoder() else { return keep }; + let dims = decoder.dimensions(); + use ExtendedColorType as T; + let grey = matches!(decoder.original_color_type(), T::L1 | T::La1 | T::L2 | T::La2 | T::L4 | T::La4 | T::L8 | T::La8 | T::L16 | T::La16); + // Pixels in a profile they are converted from are decoded, whatever the Compression settings. + // Grey pixels, which `convert` leaves as they are, keep their profile when it converts. + let to_srgb = to_srgb.filter(|_| !(grey && convert.is_some())); + // Converted to the destination as sRGB, as before, when the profile can't be read. + let mut note = None; + let source = match to_srgb.and_then(|t| Some((source_profile(bytes, if grey { *b"GRAY" } else { *b"RGB " })?, t))) { + Some((Some(p), t)) => Some((p, t)), + Some((None, _)) if convert.is_some() => { + note = Some(CONVERTED_AS_SRGB); + None + } + Some((None, _)) => return Recoded { image: None, warning: Some(UNCONVERTED), note: None }, + None => None, + }; + // Pixels that can't be decoded are kept as they are, with a warning when they were to be + // converted. + let keep = if source.is_some() { Recoded { image: None, warning: Some(UNDECODED), note: None } } else { keep }; // What the header declares is decoded within the image allowance, not whatever it asks for. if decoder.set_limits(limits()).is_err() { return keep; } - let dims = decoder.dimensions(); - use ExtendedColorType as T; - let grey = matches!(decoder.original_color_type(), T::L1 | T::La1 | T::L2 | T::La2 | T::L4 | T::La4 | T::L8 | T::La8 | T::L16 | T::La16); - let (kind, pixels) = if grey { + let (mut kind, pixels) = if grey { // Only the pixels tell monochrome images from greyscale ones: decoded unless both keep it. let (g, m) = (rule(c, Kind::Gray), rule(c, Kind::Mono)); - if kept(&g, dims, size, jpeg) && kept(&m, dims, size, jpeg) && g.instead == m.instead { - return Recoded { image: None, warning: g.instead }; + if source.is_none() && kept(&g, dims, size, jpeg) && kept(&m, dims, size, jpeg) && g.instead == m.instead { + return Recoded { image: None, warning: g.instead, note: None }; } let Ok(img) = DynamicImage::from_decoder(decoder) else { return keep }; (if bilevel(&img) { Kind::Mono } else { Kind::Gray }, Some(img)) } else { (Kind::Color, None) }; - let rule = rule(c, kind); + let mut rule = rule(c, kind); let convert = convert.filter(|_| kind == Kind::Color); - if kept(&rule, dims, size, jpeg) && convert.is_none() { - return Recoded { image: None, warning: rule.instead }; + if kept(&rule, dims, size, jpeg) && convert.is_none() && source.is_none() { + return Recoded { image: None, warning: rule.instead, note: None }; + } + let Some(mut img) = pixels.or_else(|| decoded(bytes)) else { return keep }; + if let Some((p, t)) = &source { + let srgb = match into_srgb(img, p, t.intent) { + Some(s) => Some(s), + None if convert.is_some() => { + // Converted to the destination as sRGB: decoded again (a colour image), as the + // conversion took the pixels. + note = Some(CONVERTED_AS_SRGB); + None + } + None => return Recoded { image: None, warning: Some(UNCONVERTED), note: None }, + }; + img = match srgb { + Some((srgb, changed)) => { + // The converted levels of a black-and-white image may be grey. + if kind == Kind::Mono && !bilevel(&srgb) { + (kind, rule) = (Kind::Gray, self::rule(c, Kind::Gray)); + } + // Written as it is only when nothing converts it further. + let as_is = kept(&rule, dims, size, jpeg) && convert.is_none(); + // A JPEG kept as JPEG whose samples the conversion leaves unchanged (an sRGB + // profile): its JPEG data, without the profile. + if !changed && as_is && jpeg { + let image = Image::from_jpeg_with_icc(bytes.to_vec().into(), None, interpolate).ok(); + return Recoded { image, warning: rule.instead, note: None }; + } + // Not resampled and lossless: written at its own depth. + if as_is && jpeg_quality(&rule, jpeg).is_none() { + return Recoded { image: from_decoded(srgb, None, interpolate, t.sixteen_bit), warning: rule.instead, note: None }; + } + srgb + } + None => { + let Some(img) = decoded(bytes) else { return keep }; + img + } + }; } - let Some(img) = pixels.or_else(|| decoded(bytes)) else { return keep }; // Pixels that keep their colours keep the profile they are in; converted ones are in the // destination's. let want = if kind == Kind::Color { *b"RGB " } else { *b"GRAY" }; - let icc = (profiles && convert.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| valid_icc(icc, want)); + let icc = (profiles && convert.is_none() && source.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| valid_icc(icc, want)); let rgb_icc = icc.is_some() && kind == Kind::Color; let mut rgba = resample(img, &rule, sized(&rule, dims, size)).into_rgba8(); if kind == Kind::Mono { @@ -441,7 +601,7 @@ pub(crate) fn recode( to_jpeg(&samples, space, width, height, q, interpolate, icc.as_deref().map(Vec::as_slice)) }); let image = jpeg.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate, convert, icc)); - Recoded { image, warning: rule.instead } + Recoded { image, warning: rule.instead, note } } /// A colorized image's coverage (one sample a pixel, `w` × `h`, see @@ -492,16 +652,16 @@ pub(crate) fn recode_cmyk( let image = (u64::from(w) * u64::from(h) <= MAX_EMBED_PIXELS) .then(|| Image::from_jpeg_with_icc(blob.bytes.as_ref().clone().into(), icc.map(|i| i.to_vec().into()), interpolate).ok()) .flatten(); - return Recoded { image, warning: rule.instead }; + return Recoded { image, warning: rule.instead, note: None }; } } let Some(inks) = blob.cmyk() else { - return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }) }; + return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }), note: None }; }; let (width, height) = sized(&rule, (inks.width, inks.height), size); let (inks, alpha) = match resampled_inks(inks, alpha, &rule, (width, height)) { Ok(r) => r, - Err(warning) => return Recoded { image: None, warning }, + Err(warning) => return Recoded { image: None, warning, note: None }, }; let (samples, space) = match convert { Some(p) => p.convert(&inks), @@ -510,7 +670,7 @@ pub(crate) fn recode_cmyk( // JPEG needs opaque pixels. let image = jpeg_quality(&rule, jpeg).filter(|_| alpha.is_none()).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate, icc)); let image = image.or_else(|| lossless(samples, alpha, space, width, height, interpolate, icc)); - Recoded { image, warning: rule.instead } + Recoded { image, warning: rule.instead, note: None } } /// Ink amounts (four bytes a pixel) and their alpha (one byte a pixel), if any. diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index 8114931bc4..92381e1628 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -10,8 +10,12 @@ //! colours of the other model (and Lab ones), keeping the numbers of those already in it. //! Grey stays grey. Images (and freeform gradients' images) are converted the same way, pixel //! by pixel (CMYK images too: they keep their numbers unless converted, see [`CmykPixels`]); -//! printer's marks are not. The destination is a profile name; empty, the document's -//! profile for its colour mode. +//! printer's marks are not. With `destination` and an sRGB destination, a gray or RGB image +//! that carries an ICC profile is converted from it to sRGB with the same intent and written +//! without it ([`ColorOut::image_srgb_intent`]); with `destination` and a destination its +//! pixels are converted to (CMYK, another RGB space), a colour image is converted from its +//! profile to sRGB and then to the destination. Without conversion it keeps its profile. The +//! destination is a profile name; empty, the document's profile for its colour mode. //! - Profiles ([`ProfileInclusion`]): with `all`, with `destination`, and with `taggedSource` on a //! document that was assigned profiles, colours are written in ICC-based colour spaces: CMYK //! with the CMYK profile in effect (the destination when converting to CMYK, else the @@ -50,6 +54,8 @@ struct Dest { keep: bool, /// The destination is sRGB (what images are). srgb: bool, + /// Convert to Destination was asked for (not Preserve Numbers, nor PDF/X-1a's own conversion). + destination: bool, } /// How an export writes colours (see the module documentation). @@ -233,11 +239,12 @@ impl ColorOut { }; let cms = Cms::new(&settings).map_err(|e| PdfError::BadSetting(format!("output.destination: {e}")))?; // PDF/X-1a converts without being asked: as Preserve Numbers does. - let keep = set.output.conversion != ColorConversion::Destination || cms::canonical_name(&own) == name; + let destination = set.output.conversion == ColorConversion::Destination; + let keep = !destination || cms::canonical_name(&own) == name; if model == Model::Cmyk { out.cmyk_profile = name.clone(); } - out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep }); + out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep, destination }); } out.tagged = matches!(set.standard, Standard::PdfA2b | Standard::PdfX3 | Standard::PdfX4) || (!set.standard.cmyk_only() @@ -338,6 +345,18 @@ impl ColorOut { .as_ref() } + /// The intent a gray or RGB image that carries an ICC profile is converted from it to sRGB + /// with ([`crate::images::recode`]): the colour settings' intent, with Convert to Destination + /// and an sRGB destination, also when the document's RGB space is sRGB, or a destination + /// colour images' pixels are then converted to ([`Self::pixels`]: CMYK, another RGB space). + /// `None` otherwise, Preserve Numbers and an RGB destination that is the document's own RGB + /// profile (which leaves RGB colours and RGB images as they are) included: such an image + /// keeps its profile, or its numbers are converted as sRGB ones. + pub(crate) fn image_srgb_intent(&self) -> Option { + let d = self.dest.as_ref().filter(|d| d.destination)?; + ((d.model == Model::Rgb && d.srgb) || self.pixels().is_some()).then_some(self.intent) + } + /// The CMYK profile CMYK image `blob` is tagged with ([`crate::images::cmyk_icc`]): the one it /// carries, when its numbers are kept and the Profiles setting or the standard includes source /// profiles. `None` otherwise, and in PDF/X-1a, whose images are untagged CMYK. diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 013d09f22d..683a14d1c3 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -297,6 +297,17 @@ impl Opened { bleed, } } + + /// How far page `index`'s (0-based) bleed box reaches past its trim box, `[top, bottom, left, + /// right]` in points, as the page shows (rotated); `None` past the last page, and when the bleed + /// box doesn't hold the trim box. + pub fn page_bleed(&self, index: usize) -> Option<[f64; 4]> { + let page = self.pdf.pages().get(index)?; + let init = Affine::new(page.initial_transform(true).as_coeffs()); + let (trim, bleed) = (init.transform_rect_bbox(page_box(page, CropTo::Trim)), init.transform_rect_bbox(page_box(page, CropTo::Bleed))); + let sides = [trim.y0 - bleed.y0, bleed.y1 - trim.y1, trim.x0 - bleed.x0, bleed.x1 - trim.x1]; + sides.iter().all(|b| b.is_finite() && *b >= 0.0).then_some(sides) + } } /// A PDF's pages as an import on their trim boxes sizes them ([`Opened::trim_pages`]). diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 400bc0b0a8..f36cb88e4b 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -266,7 +266,7 @@ fn images_declaring_more_than_the_allowance_are_not_decoded() { assert_eq!(crate::images::mask_alpha(&blob, 60_000, 60_000, false), None); assert_eq!(crate::images::mask_alpha(&blob, 2, 2, false), None, "another size is not decoded at all"); let c: CompressionSettings = serde_json::from_value(json!({"gray": {"downsample": "bicubic", "ppi": 72, "abovePpi": 100}})).unwrap(); - let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, true); + let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, true, None); assert!(r.image.is_none(), "kept as it is"); } @@ -529,9 +529,14 @@ fn tagged_tiffs_keep_their_icc_profile() { ); } } - // Converted pixels are in the destination's colours, not the image's profile. + // Converted pixels are in the destination's colours, not the image's profile, which the CMS + // can't read (it has no tags): they are converted as sRGB ones, with a warning. for destination in [vectorcraft_color::cms::DISPLAY_P3, vectorcraft_color::cms::GENERIC_CMYK] { - let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}})); + let mut settings: PdfSettings = serde_json::from_value(json!({"output": {"conversion": "destination", "destination": destination}})).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&tiff(&colour(), &rgb), &PdfOptions { settings, ..Default::default() }).unwrap(); + assert!(r.warnings.len() == 1 && r.warnings[0].contains("converted to the destination as sRGB"), "{destination}: {:?}", r.warnings); + let spaces = image_spaces(&r.bytes); assert!(spaces.len() == 1 && spaces[0].icc.as_deref() != Some(rgb.as_slice()), "converted to {destination}: {spaces:?}"); } // PDF/X-1a allows no ICC-based color spaces: a tagged gray TIFF (8 or 16 bits), PNG or JPEG is @@ -555,3 +560,423 @@ fn tagged_tiffs_keep_their_icc_profile() { } } } + +/// A gray ICC profile (version 2.1, display class) with a D50 white point whose tone curve is `trc`: +/// one entry, a gamma (u8Fixed8), or two, the ends of a table. 192 bytes. +fn gray_profile(trc: &[u16]) -> Vec { + let mut icc = vec![0u8; 192]; + icc[..4].copy_from_slice(&192u32.to_be_bytes()); + icc[8..12].copy_from_slice(&[2, 0x10, 0, 0]); + icc[12..16].copy_from_slice(b"mntr"); + icc[16..20].copy_from_slice(b"GRAY"); + icc[20..24].copy_from_slice(b"XYZ "); + icc[36..40].copy_from_slice(b"acsp"); + let d50 = [0x0000_F6D6u32, 0x0001_0000, 0x0000_D32D]; + for (i, v) in d50.iter().enumerate() { + icc[68 + i * 4..72 + i * 4].copy_from_slice(&v.to_be_bytes()); + } + icc[128..132].copy_from_slice(&2u32.to_be_bytes()); + let curve_len = 12 + 2 * trc.len() as u32; + for (i, (sig, at, len)) in [(b"kTRC", 156u32, curve_len), (b"wtpt", 172, 20)].into_iter().enumerate() { + let e = 132 + i * 12; + icc[e..e + 4].copy_from_slice(sig); + icc[e + 4..e + 8].copy_from_slice(&at.to_be_bytes()); + icc[e + 8..e + 12].copy_from_slice(&len.to_be_bytes()); + } + icc[156..160].copy_from_slice(b"curv"); + icc[164..168].copy_from_slice(&(trc.len() as u32).to_be_bytes()); + for (i, v) in trc.iter().enumerate() { + icc[168 + i * 2..170 + i * 2].copy_from_slice(&v.to_be_bytes()); + } + icc[172..176].copy_from_slice(b"XYZ "); + for (i, v) in d50.iter().enumerate() { + icc[180 + i * 4..184 + i * 4].copy_from_slice(&v.to_be_bytes()); + } + icc +} + +/// The samples of `img` converted from ICC profile `icc` to sRGB with `intent`, as an image +/// XObject holds them: RGB (or grey, the mean of the sRGB values, when `img` is grey) at 8 bits a +/// sample, or at 16 big-endian with `deep`. +fn srgb_samples(img: &DynamicImage, icc: &[u8], intent: vectorcraft_color::cms::Intent, deep: bool) -> Vec { + let p = vectorcraft_color::cms::IccProfile::from_bytes(None, icc).unwrap(); + let (max, n) = (if deep { 65_535.0 } else { 255.0 }, if img.color().has_color() { 3 } else { 1 }); + let values: Vec = match (n, deep) { + (3, true) => img.to_rgb16().into_raw().into_iter().map(f32::from).collect(), + (3, false) => img.to_rgb8().into_raw().into_iter().map(f32::from).collect(), + (_, true) => img.to_luma16().into_raw().into_iter().map(f32::from).collect(), + _ => img.to_luma8().into_raw().into_iter().map(f32::from).collect(), + }; + let mut out = vec![]; + for px in values.chunks(n) { + let v: Vec = px.iter().map(|v| v / max).collect(); + let rgb = p.to_srgb(&v, intent).unwrap(); + let rgb = if n == 1 { vec![rgb.iter().sum::() / 3.0] } else { rgb.to_vec() }; + for c in rgb { + let s = (c * max).round(); + if deep { out.extend_from_slice(&(s as u16).to_be_bytes()) } else { out.push(s as u8) } + } + } + out +} + +/// Do samples `a` and `b` (8 bits each, or 16 big-endian with `deep`) differ by at most `tolerance`? +fn samples_close(a: &[u8], b: &[u8], deep: bool, tolerance: i32) -> bool { + let values = |s: &[u8]| -> Vec { + if deep { s.chunks(2).map(|v| i32::from(u16::from_be_bytes([v[0], v[1]]))).collect() } else { s.iter().map(|v| i32::from(*v)).collect() } + }; + let (a, b) = (values(a), values(b)); + a.len() == b.len() && a.iter().zip(&b).all(|(a, b)| (a - b).abs() <= tolerance) +} + +/// The image XObjects of `d` exported with `settings`, which add no warning, and the file. +fn export_images(d: &Document, settings: Value) -> (Vec, Vec) { + let mut settings: PdfSettings = serde_json::from_value(settings).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + (image_spaces(&r.bytes), r.bytes) +} + +/// With Convert to Destination and an sRGB destination, a gray or RGB image that carries an ICC +/// profile is converted from it to sRGB and written without it: an 8-bit TIFF, a 16-bit one (at +/// 16 bits a sample from PDF 1.5 on, else at 8), a PNG, a gray TIFF, a resampled image and a JPEG +/// kept as JPEG. Without conversion each keeps its profile. An image in an sRGB profile keeps its +/// samples (a JPEG its JPEG data), and a black-and-white image converted to grays stays gray. +#[test] +fn convert_to_srgb_converts_tagged_images_from_their_profile() { + use vectorcraft_color::cms::{DISPLAY_P3, Intent, SRGB, icc_bytes}; + // Marked version 4.2: PDF 1.7 holds ICC profiles up to that version. + let mut p3 = icc_bytes(DISPLAY_P3).unwrap().to_vec(); + p3[8..12].copy_from_slice(&[4, 0x20, 0, 0]); + // Gamma 1.8. + let gray = gray_profile(&[0x01CD]); + let to_srgb = |more: Value| { + let mut s = json!({"output": {"conversion": "destination", "destination": SRGB}}); + s.as_object_mut().unwrap().extend(more.as_object().unwrap().clone()); + s + }; + let device = |space: &str, bits: u8, filter: &str| vec![Written { space: space.into(), icc: None, bits, filter: filter.into(), smask: false }]; + let flate = "FlateDecode"; + let deep = DynamicImage::ImageRgb16(image::ImageBuffer::from_fn(600, 600, |x, y| { + image::Rgb([((x * 109 + y) % 65_521) as u16, ((y * 257 + 3) ^ 0x5a) as u16, ((x * y) % 65_521) as u16]) + })); + let cases = [ + ("RGB TIFF", colour(), tagged(&colour(), &p3, ImageFormat::Tiff), "image/tiff", &p3, json!({}), device("DeviceRGB", 8, flate), false), + ("RGB PNG", colour(), tagged(&colour(), &p3, ImageFormat::Png), "image/png", &p3, json!({}), device("DeviceRGB", 8, flate), false), + ( + "16-bit RGB TIFF", + deep.clone(), + tagged(&deep, &p3, ImageFormat::Tiff), + "image/tiff", + &p3, + json!({"compatibility": "1.7"}), + device("DeviceRGB", 16, flate), + true, + ), + ( + "16-bit RGB TIFF, PDF 1.4", + deep.clone(), + tagged(&deep, &p3, ImageFormat::Tiff), + "image/tiff", + &p3, + json!({"compatibility": "1.4"}), + device("DeviceRGB", 8, flate), + false, + ), + ( + "gray TIFF", + colour().grayscale(), + tagged(&colour().grayscale(), &gray, ImageFormat::Tiff), + "image/tiff", + &gray, + json!({}), + device("DeviceGray", 8, flate), + false, + ), + ]; + for (what, img, bytes, mime, icc, more, want, sixteen) in cases { + let d = doc(bytes, mime); + let (spaces, pdf) = export_images(&d, to_srgb(more.clone())); + assert_eq!(spaces, want, "{what}"); + let samples = image_samples(&pdf); + // 16-bit samples are converted, then reduced to 8 bits before PDF 1.5. + let deep_source = matches!(img, DynamicImage::ImageRgb16(_)); + let want = srgb_samples(&img, icc, Intent::RelativeColorimetric, deep_source); + let want: Vec = if deep_source && !sixteen { + want.chunks(2).map(|v| (f32::from(u16::from_be_bytes([v[0], v[1]])) / 257.0).round() as u8).collect() + } else { + want + }; + assert!(samples.len() == 1 && samples_close(&samples[0], &want, sixteen, if sixteen { 2 } else { 1 }), "{what}: converted samples"); + let unconverted: Vec = match &img { + DynamicImage::ImageRgb16(s) if sixteen => s.iter().flat_map(|v| v.to_be_bytes()).collect(), + s if s.color().has_color() => s.to_rgb8().into_raw(), + s => s.to_luma8().into_raw(), + }; + assert!(!samples_close(&samples[0], &unconverted, sixteen, 0), "{what}: the samples changed"); + // Without conversion the image keeps its profile (a version 4 profile from PDF 1.5 on). + let mut plain = more; + plain["compatibility"] = json!("1.7"); + let (spaces, _) = export_images(&d, plain); + assert!( + spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(icc.as_slice()), + "{what}, no conversion: {spaces:?}" + ); + } + // Resampled, or kept as a JPEG: converted and written without the profile. + let resampled = to_srgb(json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}})); + let (spaces, _) = export_images(&doc(tagged(&colour(), &p3, ImageFormat::Tiff), "image/tiff"), resampled); + assert_eq!(spaces, device("DeviceRGB", 8, flate), "resampled"); + let (spaces, _) = export_images(&doc(tagged(&colour(), &p3, ImageFormat::Jpeg), "image/jpeg"), to_srgb(json!({}))); + assert_eq!(spaces, device("DeviceRGB", 8, "DCTDecode"), "JPEG"); + // An image in an sRGB profile keeps its samples, and a JPEG its JPEG data, written without the + // profile; so does an image whose samples the conversion leaves unchanged (black in P3). + let srgb = icc_bytes(SRGB).unwrap().to_vec(); + let (spaces, pdf) = export_images(&doc(tagged(&colour(), &srgb, ImageFormat::Png), "image/png"), to_srgb(json!({"compatibility": "1.7"}))); + assert_eq!(spaces, device("DeviceRGB", 8, flate), "sRGB PNG"); + assert!(image_samples(&pdf) == [colour().to_rgb8().into_raw()], "sRGB PNG: the samples unchanged"); + let jpeg = tagged(&colour(), &srgb, ImageFormat::Jpeg); + let (spaces, pdf) = export_images(&doc(jpeg.clone(), "image/jpeg"), to_srgb(json!({"compatibility": "1.7"}))); + assert_eq!(spaces, device("DeviceRGB", 8, "DCTDecode"), "sRGB JPEG"); + assert!(image_data(&pdf) == [jpeg], "sRGB JPEG: the JPEG data unchanged"); + let black = DynamicImage::ImageRgb8(image::RgbImage::new(600, 600)); + let (spaces, _) = export_images(&doc(tagged(&black, &p3, ImageFormat::Png), "image/png"), to_srgb(json!({"compatibility": "1.7"}))); + assert_eq!(spaces, device("DeviceRGB", 8, flate), "black P3 PNG"); + // A black-and-white image whose profile makes black a gray is written in those grays, not + // thresholded back to black and white when it is resampled. + let bilevel = + DynamicImage::ImageLuma8(image::GrayImage::from_fn(600, 600, |x, y| image::Luma([if (x / 10 + y / 10) % 2 == 0 { 0 } else { 255 }]))); + let lifted = gray_profile(&[0x3333, 0xFFFF]); + let resample = json!({"downsample": "bicubic", "ppi": 150, "abovePpi": 225}); + let (spaces, pdf) = export_images( + &doc(tagged(&bilevel, &lifted, ImageFormat::Tiff), "image/tiff"), + to_srgb(json!({"compression": {"gray": resample.clone(), "mono": resample}})), + ); + assert_eq!(spaces, device("DeviceGray", 8, flate), "lifted black and white"); + let samples = image_samples(&pdf); + assert!(samples.len() == 1 && samples[0].iter().all(|v| *v > 0) && samples[0].iter().any(|v| *v > 200), "lifted black and white: grays kept"); + // A well-formed profile the CMS can't read (no tags) is kept, with a warning. + let unread = icc_profile(b"RGB ", 600); + let mut settings: PdfSettings = serde_json::from_value(to_srgb(json!({}))).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&doc(tagged(&colour(), &unread, ImageFormat::Tiff), "image/tiff"), &PdfOptions { settings, ..Default::default() }) + .unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings); + let spaces = image_spaces(&r.bytes); + assert!(spaces.len() == 1 && spaces[0].icc.as_deref() == Some(unread.as_slice()), "unread profile: {spaces:?}"); + // A tagged image whose header declares more pixels than the decoding allowance is kept as it + // is, with a warning when it was to be converted. + let declared = |img: &DynamicImage, icc: &[u8]| { + let mut png = tagged(img, icc, ImageFormat::Png); + png[16..24].copy_from_slice(&[0, 0, 0x75, 0x30, 0, 0, 0x75, 0x30]); + let mut crc = flate2::Crc::new(); + crc.update(&png[12..29]); + png[29..33].copy_from_slice(&crc.sum().to_be_bytes()); + png + }; + let c: CompressionSettings = serde_json::from_value(json!({})).unwrap(); + let srgb_out = Some(crate::images::ToSrgb { intent: Intent::RelativeColorimetric, sixteen_bit: true }); + for (what, png) in [("RGB", declared(&colour(), &p3)), ("gray", declared(&colour().grayscale(), &gray))] { + let r = crate::images::recode(&png, (100.0, 100.0), &c, false, None, true, srgb_out); + assert!(r.image.is_none() && r.warning.is_some_and(|w| w.contains("not converted to sRGB")), "{what}: {:?}", r.warning); + let r = crate::images::recode(&png, (100.0, 100.0), &c, false, None, true, None); + assert!(r.image.is_none() && r.warning.is_none(), "{what}, no conversion: {:?}", r.warning); + // Too large to embed as it is too: the export leaves it out, and says only that. + let mut settings: PdfSettings = serde_json::from_value(to_srgb(json!({}))).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&doc(png, "image/png"), &PdfOptions { settings, ..Default::default() }).unwrap(); + assert!(r.warnings.len() == 1 && r.warnings[0].contains("missing from the picture"), "{what}, exported: {:?}", r.warnings); + } +} + +/// An RGB ICC profile (version 2.1, display class, XYZ connection space) of two lookup tables of +/// two grid points each: the colorimetric one (`A2B1`) maps device RGB linearly onto sRGB's +/// primaries adapted to D50, the perceptual one (`A2B0`) onto half of them. 436 bytes. +fn rgb_two_intents() -> Vec { + let mut icc = vec![0u8; 436]; + icc[..4].copy_from_slice(&436u32.to_be_bytes()); + icc[8..12].copy_from_slice(&[2, 0x10, 0, 0]); + icc[12..16].copy_from_slice(b"mntr"); + icc[16..20].copy_from_slice(b"RGB "); + icc[20..24].copy_from_slice(b"XYZ "); + icc[36..40].copy_from_slice(b"acsp"); + let d50 = [0x0000_F6D6u32, 0x0001_0000, 0x0000_D32D]; + for (i, v) in d50.iter().enumerate() { + icc[68 + i * 4..72 + i * 4].copy_from_slice(&v.to_be_bytes()); + } + icc[128..132].copy_from_slice(&3u32.to_be_bytes()); + for (i, (sig, at, len)) in [(b"A2B0", 168u32, 124u32), (b"A2B1", 292, 124), (b"wtpt", 416, 20)].into_iter().enumerate() { + let e = 132 + i * 12; + icc[e..e + 4].copy_from_slice(sig); + icc[e + 4..e + 8].copy_from_slice(&at.to_be_bytes()); + icc[e + 8..e + 12].copy_from_slice(&len.to_be_bytes()); + } + let primaries = [[0.4361f32, 0.2225, 0.0139], [0.3851, 0.7169, 0.0971], [0.1431, 0.0606, 0.7141]]; + for (at, scale) in [(168usize, 0.5f32), (292, 1.0)] { + let mut lut = vec![]; + lut.extend_from_slice(b"mft2"); + lut.extend_from_slice(&[0, 0, 0, 0, 3, 3, 2, 0]); + // The identity matrix, as s15Fixed16 numbers. + for v in [1u32, 0, 0, 0, 1, 0, 0, 0, 1] { + lut.extend_from_slice(&(v << 16).to_be_bytes()); + } + lut.extend_from_slice(&2u16.to_be_bytes()); + lut.extend_from_slice(&2u16.to_be_bytes()); + for _ in 0..3 { + lut.extend_from_slice(&[0, 0, 0xFF, 0xFF]); + } + for corner in 0..8u32 { + let on = [corner >> 2 & 1, corner >> 1 & 1, corner & 1].map(|b| b as f32); + let xyz = primaries.iter().zip(on).fold([0.0f32; 3], |sum, (p, o)| [sum[0] + o * p[0], sum[1] + o * p[1], sum[2] + o * p[2]]); + for v in xyz { + lut.extend_from_slice(&((v * scale * 32_768.0).round() as u16).to_be_bytes()); + } + } + for _ in 0..3 { + lut.extend_from_slice(&[0, 0, 0xFF, 0xFF]); + } + icc[at..at + 124].copy_from_slice(&lut); + } + icc[416..420].copy_from_slice(b"XYZ "); + for (i, v) in d50.iter().enumerate() { + icc[424 + i * 4..428 + i * 4].copy_from_slice(&v.to_be_bytes()); + } + icc +} + +/// The conversion of a tagged image to an sRGB destination uses the colour settings' intent: an +/// RGB profile with a perceptual table and a colorimetric one converts with the table the intent +/// picks. +#[test] +fn convert_to_srgb_converts_images_with_the_color_settings_intent() { + use vectorcraft_color::cms::{self, ColorSettings, Intent, SRGB}; + let icc = rgb_two_intents(); + let img = colour(); + let d = doc(tagged(&img, &icc, ImageFormat::Tiff), "image/tiff"); + let settings = json!({"output": {"conversion": "destination", "destination": SRGB}}); + let mut seen = vec![]; + for intent in [Intent::RelativeColorimetric, Intent::Perceptual] { + let active = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, ..ColorSettings::default() }).unwrap()); + let samples = image_samples(&cms::with_active(active, || export_images(&d, settings.clone())).1); + let want = srgb_samples(&img, &icc, intent, false); + assert!(samples.len() == 1 && samples_close(&samples[0], &want, false, 1), "{intent:?}: converted samples"); + seen.push(samples[0].clone()); + } + assert!(seen[0] != seen[1], "the two intents give different samples"); +} + +/// A 600 × 600 image of sixteen blocks of colour, saturated ones (the primaries and secondaries) +/// among them. +fn colour_blocks() -> DynamicImage { + const LEVELS: [u8; 4] = [0, 90, 200, 255]; + image::RgbImage::from_fn(600, 600, |x, y| { + let (i, j) = ((x / 150) as usize, (y / 150) as usize); + image::Rgb([LEVELS[i], LEVELS[j], LEVELS[(i + 2 * j) % 4]]) + }) + .into() +} + +/// The samples (8 bits each) of `img`'s pixels as `convert` gives each one's: each distinct +/// colour converts once. +fn converted_samples(img: &DynamicImage, convert: impl Fn([u8; 3]) -> Vec) -> Vec { + let mut seen: std::collections::HashMap<[u8; 3], Vec> = std::collections::HashMap::new(); + let mut out = vec![]; + for p in img.to_rgb8().pixels() { + out.extend_from_slice(seen.entry(p.0).or_insert_with(|| convert(p.0))); + } + out +} + +/// The largest difference between samples `a` and `b` (8 bits each) and where it is: (difference, +/// sample index, the two samples), or the two lengths when they differ. +fn max_diff(a: &[u8], b: &[u8]) -> String { + if a.len() != b.len() { + return format!("lengths {} and {}", a.len(), b.len()); + } + let worst = a.iter().zip(b).enumerate().max_by_key(|(_, (a, b))| a.abs_diff(**b)); + worst.map_or_else(String::new, |(i, (x, y))| format!("largest difference {} at sample {i}: {x} vs {y}", x.abs_diff(*y))) +} + +/// With Convert to Destination and a CMYK destination, or an RGB one other than sRGB, a colour +/// image that carries an ICC profile is converted from that profile (to sRGB, then to the +/// destination) rather than from its numbers read as sRGB ones; an untagged image is converted +/// from sRGB as before. A gray image keeps its profile (gray stays gray), and an RGB profile the +/// CMS can't read leaves the numbers converted as sRGB ones, with a warning. An RGB destination +/// other than sRGB that is the document's own RGB profile leaves RGB images as they are, so the image keeps its +/// profile. +#[test] +fn convert_to_destination_converts_tagged_images_from_their_profile() { + use vectorcraft_color::cms::{self, ColorSettings, DISPLAY_P3, GENERIC_CMYK, Intent, WIDE_GAMUT_RGB, icc_bytes}; + let intent = Intent::RelativeColorimetric; + let active = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, ..ColorSettings::default() }).unwrap()); + let mut p3 = icc_bytes(DISPLAY_P3).unwrap().to_vec(); + p3[8..12].copy_from_slice(&[4, 0x20, 0, 0]); + let p3_profile = cms::IccProfile::from_bytes(None, &p3).unwrap(); + // A P3 pixel's sRGB samples. + let from_p3 = |rgb: [u8; 3]| p3_profile.to_srgb(&rgb.map(|v| f32::from(v) / 255.0), intent).unwrap().map(|v| (v * 255.0).round() as u8); + let img = colour_blocks(); + let tagged_png = doc(tagged(&img, &p3, ImageFormat::Png), "image/png"); + let untagged_png = doc(encoded(img.clone(), ImageFormat::Png), "image/png"); + let device = |space: &str| vec![Written { space: space.into(), icc: None, bits: 8, filter: "FlateDecode".into(), smask: false }]; + // Each destination: the space images are written in, and how an sRGB pixel's samples convert + // to it (the tables the export builds from the destination's color settings). + let cmyk = cms::Cms::new(&ColorSettings { intent, cmyk: GENERIC_CMYK.into(), ..ColorSettings::default() }).unwrap(); + let wide = cms::Cms::new(&ColorSettings { intent, rgb: WIDE_GAMUT_RGB.into(), ..ColorSettings::default() }).unwrap(); + let cmy = cms::ProofLut::build(|rgb| { + let v = cmyk.srgb_to_cmyk(rgb, intent); + [v[0], v[1], v[2]] + }); + let black = cms::ProofLut::build(|rgb| [cmyk.srgb_to_cmyk(rgb, intent)[3], 0.0, 0.0]); + let wide_lut = cms::ProofLut::build(|rgb| wide.srgb_to_rgb(rgb)); + let to_cmyk = |rgb: [u8; 3]| [cmy.apply8(rgb).as_slice(), &black.apply8(rgb)[..1]].concat(); + let to_wide = |rgb: [u8; 3]| wide_lut.apply8(rgb).to_vec(); + type ToDest<'a> = &'a dyn Fn([u8; 3]) -> Vec; + let destinations: [(&str, &str, ToDest<'_>); 2] = [(GENERIC_CMYK, "DeviceCMYK", &to_cmyk), (WIDE_GAMUT_RGB, "DeviceRGB", &to_wide)]; + for (name, space, to_dest) in destinations { + let settings = json!({"output": {"conversion": "destination", "destination": name}}); + let export = |d: &Document| cms::with_active(active.clone(), || export_images(d, settings.clone())); + let (spaces, pdf) = export(&tagged_png); + assert_eq!(spaces, device(space), "{name}: tagged"); + let samples = image_samples(&pdf); + let want = converted_samples(&img, |rgb| to_dest(from_p3(rgb))); + let as_srgb = converted_samples(&img, to_dest); + assert!( + samples.len() == 1 && samples_close(&samples[0], &want, false, 1), + "{name}: converted from the profile, {}", + max_diff(&samples[0], &want) + ); + assert!(!samples_close(&samples[0], &as_srgb, false, 8), "{name}: not converted as sRGB"); + // An untagged image is converted from sRGB, as before. + let (spaces, pdf) = export(&untagged_png); + assert_eq!(spaces, device(space), "{name}: untagged"); + let samples = image_samples(&pdf); + assert!( + samples.len() == 1 && samples_close(&samples[0], &as_srgb, false, 1), + "{name}: untagged converted as sRGB, {}", + max_diff(&samples[0], &as_srgb) + ); + } + // A destination that is the document's own RGB profile leaves RGB images as they are: the + // image keeps its profile, as untagged images keep their numbers. + let own = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, rgb: WIDE_GAMUT_RGB.into(), ..ColorSettings::default() }).unwrap()); + let settings = json!({"compatibility": "1.7", "output": {"conversion": "destination", "destination": WIDE_GAMUT_RGB}}); + let (spaces, _) = cms::with_active(own, || export_images(&tagged_png, settings)); + assert!(spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(p3.as_slice()), "own RGB profile: {spaces:?}"); + // A gray image keeps its gray profile in a CMYK destination. + let gray = gray_profile(&[0x01CD]); + let settings = json!({"output": {"conversion": "destination", "destination": GENERIC_CMYK}}); + let gray_doc = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Tiff), "image/tiff"); + let (spaces, _) = cms::with_active(active.clone(), || export_images(&gray_doc, settings.clone())); + assert!(spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(gray.as_slice()), "gray: {spaces:?}"); + // A profile the CMS can't read: converted as sRGB, with a warning. + let unread = icc_profile(b"RGB ", 600); + let mut s: PdfSettings = serde_json::from_value(settings).unwrap(); + s.compression.compress_text = false; + let unread_doc = doc(tagged(&img, &unread, ImageFormat::Png), "image/png"); + let r = cms::with_active(active, || export_with_report(&unread_doc, &PdfOptions { settings: s, ..Default::default() })).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("converted to the destination as sRGB")), "{:?}", r.warnings); + assert_eq!(image_spaces(&r.bytes), device("DeviceCMYK"), "unread profile"); + let as_srgb = converted_samples(&img, to_cmyk); + assert!(image_samples(&r.bytes).first().is_some_and(|s| samples_close(s, &as_srgb, false, 1)), "unread profile: converted as sRGB"); +} diff --git a/crates/pdf/src/tests/realtext.rs b/crates/pdf/src/tests/realtext.rs index a36cea3ee7..77be459e9f 100644 --- a/crates/pdf/src/tests/realtext.rs +++ b/crates/pdf/src/tests/realtext.rs @@ -304,6 +304,26 @@ fn real_text_lands_within_half_a_point_of_the_outlines() { } } +/// Type on an open path that starts before the path writes only the characters drawn on the path, +/// as text and as outlines. +#[test] +fn type_starting_before_its_open_path_writes_only_what_is_drawn() { + let mut line = BezPath::new(); + line.move_to((20.0, 150.0)); + line.line_to((380.0, 150.0)); + let mut t = TextObject::point(Point::ZERO, "A C DEF", style(20.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + let laid = vectorcraft_text::layout(FontDb::global(), &t); + // Four glyphs, two of them spaces, and a little more before the start: "DEF" is drawn. + let lead: f64 = laid.glyphs.iter().take(4).map(|g| g.advance).sum::() + 1.0; + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -lead / 360.0, end: None }; + let d = doc(vec![t]); + assert_eq!(texts(&import_as(&pdf(&d, false).bytes, TextAs::Text)).concat(), "DEF"); + let outlined = ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)); + assert!(outlined.x0 > 20.0 - 1e-6, "{outlined:?}"); +} + /// Area type 200 points wide holding `text`, its paragraphs in lists `lists`, with an 18 pt /// hanging indent. fn listed(text: &str, lists: &[Option]) -> TextObject { diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 27e1e7e1e9..d2d228154f 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -117,6 +117,24 @@ fn variable_font_settings_are_not_highlighted_as_substituted_glyphs() { assert!(g.alternate_glyphs(&t).elements().is_empty()); } +/// Type in a missing font that starts before its open path: only the characters drawn on the path +/// are highlighted as substituted. +#[test] +fn characters_before_an_open_path_are_not_highlighted() { + let _steady = fonts_steady(); + let style = CharStyle { size: 20.0, font_family: "No Such Family 5932".into(), ..Default::default() }; + let mut t = TextObject::point(Point::ZERO, "MMMM", style); + let mut line = vectorcraft_geom::BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let whole = text_geom(&t).substituted_fonts; + assert!(!whole.elements().is_empty()); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.9, end: None }; + let g = text_geom(&t); + assert!(g.all.elements().is_empty() && g.substituted_fonts.elements().is_empty(), "{:?}", g.substituted_fonts); +} + /// Type in a composite font draws each class of character in its part's face, which is not a /// substitute: Document Setup's highlight of substituted glyphs leaves it alone. #[test] diff --git a/crates/render/src/text_geom.rs b/crates/render/src/text_geom.rs index 05228eb209..eaa15a6841 100644 --- a/crates/render/src/text_geom.rs +++ b/crates/render/src/text_geom.rs @@ -183,7 +183,8 @@ pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeo let (mut substituted_fonts, mut substituted_glyphs, mut alternate_candidates) = (BezPath::new(), BezPath::new(), vec![]); let plain = t.plain_text(); let (mut images, mut colours) = (vec![], vec![]); - for g in &layout.glyphs { + // Glyphs before the start of an open path draw nothing and have no highlight cells. + for g in layout.glyphs.iter().filter(|g| !g.off_path) { push(&mut all, g.run, &g.outline); if let Some((image, xf)) = g.placed_image() { images.push((g.run, image.clone(), xf)); diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 36744c8de5..e7d869e6f6 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -28,6 +28,8 @@ serde_json = { workspace = true } # SVGZ (gzip, pure-Rust backend: builds for wasm). flate2 = { workspace = true } image = { workspace = true } +# Image data URLs decoded as usvg decodes them, within a byte limit (MIT OR Apache-2.0; already used by usvg). +data-url = "0.3.2" # Colour / transform / length parsing for the fallback (MIT OR Apache-2.0; already used by usvg). svgtypes = "0.16" simplecss = "0.2.2" diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 5b0b921e32..2bfa7c5e5c 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -168,8 +168,8 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { if spacing != 0.0 { p.push(("letter-spacing", len(spacing / 1000.0 * st.size * st.v_scale / 100.0))); } - if st.kerning.is_some() { - // Manual kerning replaces the font's pair kerning. + if st.kerning.is_some() || st.glyph_form.is_width() { + // Manual kerning replaces the font's pair kerning, and a monospaced width form turns it off. p.push(("font-kerning", "none".into())); } let mut v: Vec = st @@ -190,8 +190,9 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { .filter(|(tag, _)| tag.len() == 4 && tag.bytes().all(|b| b.is_ascii_alphanumeric())) .map(|(tag, value)| format!("\"{tag}\" {value}")) .collect(); - // Proportional Metrics: `palt` (SVG writes vertical type as outlines). - if st.proportional_metrics { + // Proportional Metrics: `palt` (SVG writes vertical type as outlines), not with a monospaced + // width form, which sets the characters on a fixed width. + if st.proportional_metrics && !st.glyph_form.is_width() { v.push("\"palt\" 1".into()); } // The East Asian glyph form: its one feature. @@ -876,4 +877,24 @@ mod tests { assert_eq!(css_string("w\r\""), r#""w\d \"\3c /style\3e ""#); assert_eq!(css_string("wght"), "\"wght\""); } + + /// A monospaced width form sets the characters on a fixed width: Proportional Metrics' `palt` + /// is not written with it and the font's pair kerning is turned off, and with the other forms + /// `palt` is written and kerning stays. + #[test] + fn width_forms_leave_out_proportional_metrics() { + use vectorcraft_doc::GlyphForm; + let prop = |form, key: &str| { + let st = CharStyle { proportional_metrics: true, glyph_form: form, ..CharStyle::default() }; + type_props(&st, &|v| format!("{v}px")).into_iter().find(|(k, _)| *k == key).map(|(_, v)| v) + }; + assert_eq!(prop(GlyphForm::Default, "font-feature-settings").as_deref(), Some("\"palt\" 1")); + assert_eq!(prop(GlyphForm::Jis78, "font-feature-settings").as_deref(), Some("\"palt\" 1, \"jp78\" 1")); + assert_eq!(prop(GlyphForm::Jis78, "font-kerning"), None); + for form in [GlyphForm::FullWidth, GlyphForm::HalfWidth, GlyphForm::ThirdWidth, GlyphForm::QuarterWidth] { + let tag = std::str::from_utf8(form.feature().unwrap()).unwrap(); + assert_eq!(prop(form, "font-feature-settings"), Some(format!("\"{tag}\" 1")), "{form:?}"); + assert_eq!(prop(form, "font-kerning").as_deref(), Some("none"), "{form:?}"); + } + } } diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index ccd5f56806..2f8e8b8a1b 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -571,6 +571,9 @@ impl Writer<'_> { let base = self.char_props(&drawn.first_style(), space); let mut props = base.clone(); let (from, to) = t.kind.path_span().unwrap_or((start, 1.0)); + // Type on an open path may start before the path: a negative `startOffset`, and a reader + // does not draw the characters before the path's start, as the layout does not. + let from = t.kind.path_text_start().unwrap_or(from); // The anchor sits where the layout aligns the line within its span. let at = match t.para_at(0).justify { Justify::Center | Justify::JustifyCenter => { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 5267acd279..287569b859 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -20,7 +20,7 @@ use text::TextSlots; pub use depth::MAX_ELEMENT_DEPTH; pub(crate) use depth::xml_too_deep; -pub use files::{MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT}; +pub use files::{MAX_DATA_URL_BYTES, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT}; pub(crate) use fx::BLEND; /// Most objects an imported SVG may expand to: a guard against `` and nested-image @@ -205,7 +205,7 @@ pub(crate) fn import_finding( symbol_suffixes: HashMap::new(), pattern_suffixes: HashMap::new(), blends: fx::blends(&xml, &scan.markers), - filter_sources: fx::sources(&xml, &scan.markers), + filter_sources: fx::sources(&xml, &scan.markers, |h| files.data_href(h)), files, non_scaling: found.non_scaling, data: found.data, diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index fe3335fb4e..c60d22f983 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -11,6 +11,12 @@ //! A raster image, linked or a data URL, whose orientation (EXIF, or a TIFF's Orientation tag) //! turns or flips its pixels is turned before usvg gets it, so it is sized, fitted and stored as //! it is shown. +//! +//! usvg decodes a data URL each time it converts the element holding it, a `` copy +//! included, before the resolver sees it. So before usvg reads an SVG (the imported one, and +//! each SVG image), each `` and `` href that is a data URL is changed to a key, +//! and the resolver decodes the URL once, the first time a key comes, within what the data URLs +//! may decode to together ([`MAX_DATA_URL_BYTES`]); every later use of it gets the same image. use std::collections::HashMap; use std::path::Path; @@ -34,6 +40,13 @@ pub const MAX_NESTED_SVG_READS: usize = 1024; /// Most bytes of nested SVG image text an imported SVG may read, decoded or not, all reads together. pub const MAX_NESTED_SVG_TEXT: u64 = 512 << 20; +/// Most bytes the image data URLs an imported SVG shows may decode to, those in its SVG images +/// included, all together; each URL is decoded once, however often it is shown. +pub const MAX_DATA_URL_BYTES: u64 = 512 << 20; + +/// What a data URL's key starts with, the URL's number following it. +const DATA_URL_KEY: &str = "vectorcraft-data-url-"; + /// The files an SVG's images link to. #[derive(Default)] pub(in crate::import) struct Files { @@ -74,6 +87,12 @@ pub(in crate::import) struct Files { /// The CMYK TIFF stored in place of a turned CMYK image, by the address of the PNG usvg got /// (kept here too, so the address stays its own). cmyk: Mutex>, + /// The data URLs changed to keys before usvg read them. + data: Mutex, + /// The bytes the image data URLs decoded to so far, whether or not they are images. + data_bytes: AtomicU64, + /// Images were left out for their data URLs decoding past [`MAX_DATA_URL_BYTES`]. + too_much_data: AtomicBool, /// What usvg makes of the SVG and of the SVG images read so far, all together ([`budget`]): /// elements, bytes of copied style values and selector tests. spent: Mutex, @@ -82,6 +101,34 @@ pub(in crate::import) struct Files { refused: Mutex>, } +/// The hrefs changed to keys, by number: an href's key is [`DATA_URL_KEY`] and its number. +#[derive(Default)] +struct DataUrls { + /// The number of each href. + numbers: HashMap, usize>, + /// Each href: a data URL, then what it decoded to, or a link. + urls: Vec, + /// Each href as it was written, for the filters kept as written ([`Files::data_href`]). + hrefs: Vec>, +} + +enum DataUrl { + Encoded(Arc), + Decoded(Decoded), + /// A link that starts as a key does, looked up as it is. + Link(Arc), +} + +/// What a data URL decoded to, as usvg gets it. +#[derive(Clone)] +enum Decoded { + Raster(RasterKind, Bytes), + /// SVG text (unpacked when it was SVGZ), read where it is shown ([`Files::svg`]). + Svg(Arc), + /// Not an image VectorCraft reads, or left out. + Nothing, +} + /// Encoded image bytes, shared. type Bytes = Arc>; @@ -254,7 +301,7 @@ fn key(bytes: &Arc>) -> usize { impl Files { /// Read the files the `` elements of `xml` (the text `svg`) link to. Placeholders /// stretch to the image's box (`preserveAspectRatio="none"`), which the file fills once - /// relinked. + /// relinked. The data URLs of its images are changed to keys ([`Files::take_data_urls`]). pub(in crate::import) fn read(svg: &str, xml: &roxmltree::Document, opts: &ImportOptions, edits: &mut Edits, warnings: &mut Vec) -> Self { let mut files = Files::default(); let mut reading = Reading { @@ -269,9 +316,41 @@ impl Files { measured: budget::Cost::default(), }; files.sources = files.links_of(svg, xml, opts.folder, None, edits, &mut reading); + files.take_data_urls(xml, edits); files } + /// Change each href of the `` and `` elements of `xml` that usvg would decode + /// as a data URL to that URL's key, numbering the URLs not seen before: usvg gives a key to + /// the resolver as it is ([`Files::data_url`]). An href that starts as a key does, a link, + /// gets a key too, which the resolver reads as that link. + fn take_data_urls(&self, xml: &roxmltree::Document, edits: &mut Edits) { + let Ok(mut data) = self.data.lock() else { return }; + let hrefs = xml + .descendants() + .filter(|n| n.is_element() && matches!(n.tag_name().name(), "image" | "feImage")) + .flat_map(|n| n.attributes()) + .filter(|a| a.name() == "href" && matches!(a.namespace(), None | Some("http://www.w3.org/1999/xlink"))); + for a in hrefs { + let url = data_url::DataUrl::process(a.value()).is_ok(); + if !url && !a.value().starts_with(DATA_URL_KEY) { + continue; + } + let n = match data.numbers.get(a.value()) { + Some(n) => *n, + None => { + let href: Arc = Arc::from(a.value()); + let n = data.urls.len(); + data.numbers.insert(href.clone(), n); + data.hrefs.push(href.clone()); + data.urls.push(if url { DataUrl::Encoded(href) } else { DataUrl::Link(href) }); + n + } + }; + edits.set_value(a, &format!("{DATA_URL_KEY}{n}")); + } + } + /// What usvg gets for each href the `` elements of `xml` (the text `svg`) link to, /// relative paths taken from `folder`. `inside` is how deep the SVG images `xml` shows nest in /// it and the SVGs around it, measured when an SVG file is read (`None` for `xml` itself). @@ -507,6 +586,9 @@ impl Files { MAX_NESTED_SVG_TEXT >> 20 )); } + if self.too_much_data.load(Ordering::Relaxed) { + notes.push(format!("images were left out: together their data URLs decode to more than {} MB", MAX_DATA_URL_BYTES >> 20)); + } notes } @@ -537,47 +619,145 @@ impl Files { } } - /// usvg's image resolver, for an SVG whose elements nest `height` deep: data URLs as usvg - /// reads them, other hrefs only from the files read. SVG images are read here - /// ([`Files::svg_data`], [`Files::svg`]), never by usvg's own resolver, which caps neither how - /// deep they nest nor what they unpack to. + /// usvg's image resolver, for an SVG whose elements nest `height` deep: data URLs by their + /// keys ([`Files::data_url`]), and those usvg decoded itself as it reads them, within + /// [`MAX_DATA_URL_BYTES`]; other hrefs only from the files read. SVG images are read here + /// ([`Files::svg`]), never by usvg's own resolver, which caps neither how deep they nest nor + /// what they unpack to. pub(in crate::import) fn resolver(&self, height: usize) -> ImageHrefResolver<'_> { self.depth.store(height, Ordering::Relaxed); ImageHrefResolver { - // By type as usvg reads them, and TIFF; one without a type (`text/plain`) by its - // bytes: one of those rasters, else SVG. A raster as [`shown`] gives it. resolve_data: Box::new(move |mime, bytes, opts| { - use image::ImageFormat as F; - let format = match mime { - "image/jpg" | "image/jpeg" => F::Jpeg, - "image/png" => F::Png, - "image/gif" => F::Gif, - "image/webp" => F::WebP, - "image/tiff" => F::Tiff, - "image/svg+xml" => return self.svg_data(&bytes, opts), - "text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) { - Some(format) => format, - None => return self.svg_data(&bytes, opts), - }, - _ => return None, - }; - let (kind, bytes) = self.keep(shown(bytes, format)?); - Some(kind(bytes)) + let spent = self.data_bytes.load(Ordering::Relaxed).saturating_add(bytes.len() as u64); + if spent > MAX_DATA_URL_BYTES { + return leave_out(&self.too_much_data, true); + } + self.data_bytes.store(spent, Ordering::Relaxed); + self.show(self.decoded(mime, bytes), opts) }), - resolve_string: Box::new(move |h, opts| match self.find(h)? { - Source::Raster(kind, bytes) => Some(kind(bytes)), - Source::Svg(file) => self.svg(&file.text.clone(), Some(file), opts), + resolve_string: Box::new(move |h, opts| { + let link = match self.data_number(h).map(|n| self.data_url(n, opts)) { + Some(Ok(image)) => return image, + Some(Err(link)) => link, + None => Arc::from(h), + }; + match self.find(&link)? { + Source::Raster(kind, bytes) => Some(kind(bytes)), + Source::Svg(file) => self.svg(&file.text.clone(), Some(file), opts), + } }), } } - /// The SVG image of data `bytes` (SVG or SVGZ), as [`Files::svg`] reads it. Unpacked here, - /// within what the SVGZ images have left, so usvg unpacks nothing itself; past it, `None`, - /// noted. - fn svg_data(&self, bytes: &[u8], opts: &usvg::Options) -> Option { + /// The number of the href whose key is `h`. + fn data_number(&self, h: &str) -> Option { + let n: usize = h.strip_prefix(DATA_URL_KEY)?.parse().ok()?; + let known = n < self.data.lock().ok()?.urls.len(); + (known && h.len() == DATA_URL_KEY.len() + n.to_string().len()).then_some(n) + } + + /// The href whose key is `h`, as it was written: a filter kept as an effect holds its + /// `` hrefs as written, not their keys ([`super::fx::sources`]). + pub(in crate::import) fn data_href(&self, h: &str) -> Option> { + let n = self.data_number(h)?; + self.data.lock().ok()?.hrefs.get(n).cloned() + } + + /// The image of data URL `n`: decoded the first time ([`Files::decode`]), then kept; the link + /// when href `n` is one. + fn data_url(&self, n: usize, opts: &usvg::Options) -> Result, Arc> { + // The lock is let go before the image is shown: an SVG image takes it again. + let state = match self.data.lock().ok().as_deref().and_then(|d| d.urls.get(n)) { + Some(DataUrl::Decoded(decoded)) => Ok(decoded.clone()), + Some(DataUrl::Encoded(url)) => Err(url.clone()), + Some(DataUrl::Link(link)) => return Err(link.clone()), + None => return Ok(None), + }; + let url = match state { + Ok(decoded) => return Ok(self.show(decoded, opts)), + Err(url) => url, + }; + let decoded = self.decode(&url); + if let Ok(mut data) = self.data.lock() + && let Some(slot) = data.urls.get_mut(n) + { + *slot = DataUrl::Decoded(decoded.clone()); + } + Ok(self.show(decoded, opts)) + } + + /// What data URL `url` decodes to, as usvg decodes it, in what is left of + /// [`MAX_DATA_URL_BYTES`]: decoding stops where it would go past, the image left out, noted. + fn decode(&self, url: &str) -> Decoded { + let Ok(parsed) = data_url::DataUrl::process(url) else { return Decoded::Nothing }; + let spent = self.data_bytes.load(Ordering::Relaxed); + let room = MAX_DATA_URL_BYTES.saturating_sub(spent); + let mut body = Vec::new(); + let decoded = parsed.decode(|bytes| { + if (body.len() as u64).saturating_add(bytes.len() as u64) > room { + return Err(()); + } + body.extend_from_slice(bytes); + Ok(()) + }); + self.data_bytes.store(spent.saturating_add(body.len() as u64), Ordering::Relaxed); + match decoded { + Ok(_) => {} + Err(data_url::forgiving_base64::DecodeError::WriteError(())) => { + self.too_much_data.store(true, Ordering::Relaxed); + return Decoded::Nothing; + } + Err(_) => return Decoded::Nothing, + } + let mime = format!("{}/{}", parsed.mime_type().type_, parsed.mime_type().subtype); + self.decoded(&mime, Arc::new(body)) + } + + /// What usvg gets for data `bytes` of type `mime`: by type as usvg reads them, and TIFF; one + /// without a type (`text/plain`) by its bytes: one of those rasters, else SVG. A raster as + /// [`shown`] gives it; SVG or SVGZ as [`Files::svg_text`] gives it. + fn decoded(&self, mime: &str, bytes: Bytes) -> Decoded { + use image::ImageFormat as F; + let format = match mime { + "image/jpg" | "image/jpeg" => F::Jpeg, + "image/png" => F::Png, + "image/gif" => F::Gif, + "image/webp" => F::WebP, + "image/tiff" => F::Tiff, + "image/svg+xml" => return self.svg_text(&bytes), + "text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) { + Some(format) => format, + None => return self.svg_text(&bytes), + }, + _ => return Decoded::Nothing, + }; + match shown(bytes, format) { + Some(shown) => { + let (kind, bytes) = self.keep(shown); + Decoded::Raster(kind, bytes) + } + None => Decoded::Nothing, + } + } + + /// The text of SVG image data `bytes` (SVG or SVGZ). Unpacked here, within what the SVGZ + /// images have left, so usvg unpacks nothing itself; past it, nothing, noted. + fn svg_text(&self, bytes: &[u8]) -> Decoded { match crate::text_within(bytes, &self.unpacked) { - Ok(text) => self.svg(&text, None, opts), - Err(e) => leave_out(&self.too_big, matches!(e, SvgError::TooBig(_))), + Ok(text) => Decoded::Svg(Arc::from(text.as_ref())), + Err(e) => { + leave_out(&self.too_big, matches!(e, SvgError::TooBig(_))); + Decoded::Nothing + } + } + } + + /// The image usvg gets for `decoded`: an SVG image read here, where it is shown. + fn show(&self, decoded: Decoded, opts: &usvg::Options) -> Option { + match decoded { + Decoded::Raster(kind, bytes) => Some(kind(bytes)), + Decoded::Svg(text) => self.svg(&text, None, opts), + Decoded::Nothing => None, } } @@ -641,6 +821,9 @@ impl Files { // usvg's parser would overflow on it, as on the SVG holding it (`import`). super::convolve_matrices_ok(&xml).ok()?; self.set_spent(spent.add(cost)); + let mut edits = Edits::default(); + self.take_data_urls(&xml, &mut edits); + let keyed = edits.apply(text); self.depth.store(used + height, Ordering::Relaxed); let within = self.within.lock().ok().map(|mut w| w.push(file)).is_some(); // usvg's `Tree::from_data_nested`, except that hrefs to files go to the resolver. @@ -659,7 +842,7 @@ impl Files { }, ..usvg::Options::default() }; - let tree = usvg::Tree::from_str(text, &nested); + let tree = usvg::Tree::from_str(&keyed, &nested); if within && let Ok(mut w) = self.within.lock() { w.pop(); } @@ -1096,6 +1279,178 @@ mod tests { assert_eq!(files.left_out().len(), 2, "{:?}", files.left_out()); } + /// A base64 data URL of `bytes` of type `mime`. + fn data(mime: &str, bytes: &[u8]) -> String { + format!("data:{mime};base64,{}", crate::base64_encode(bytes)) + } + + /// A PNG of `w` × 1. + fn png_of(w: u32) -> Vec { + let mut out = vec![]; + image::RgbaImage::new(w, 1).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + } + + /// SVG text of 2 × 2 holding `body`. + fn doc(body: &str) -> String { + format!(r#"{body}"#) + } + + /// `svg` read as the import reads it, its data URLs changed to keys first, `spent` bytes of + /// [`MAX_DATA_URL_BYTES`] already decoded: the files, and the PNG bytes of the images usvg + /// made, filters' included, in order. + fn read_keyed(svg: &str, spent: u64) -> (Files, Vec>>) { + let xml = super::super::parse(svg).unwrap(); + let mut edits = Edits::default(); + let files = Files::read(svg, &xml, &ImportOptions::default(), &mut edits, &mut vec![]); + files.data_bytes.store(spent, Ordering::Relaxed); + let text = edits.apply(svg).into_owned(); + let tree = { + let height = depth::height(&xml, MAX_ELEMENT_DEPTH).unwrap(); + let opts = usvg::Options { image_href_resolver: files.resolver(height), ..usvg::Options::default() }; + usvg::Tree::from_str(&text, &opts).unwrap() + }; + // Filters' images and SVG images' trees are subroots. + fn pngs(g: &usvg::Group, out: &mut Vec>>) { + for n in g.children() { + match n { + usvg::Node::Group(g) => pngs(g, out), + usvg::Node::Image(im) => { + if let ImageKind::PNG(b) = im.kind() { + out.push(b.clone()); + } + } + _ => {} + } + n.subroots(|g| pngs(g, out)); + } + } + let mut out = vec![]; + pngs(tree.root(), &mut out); + (files, out) + } + + /// A data URL shown by many `` elements is decoded once, and every use gets the same + /// image, where usvg decoded it again for each. + #[test] + fn a_data_url_is_decoded_once_however_often_it_is_used() { + let png = png(); + let uses = r##""##.repeat(64); + let svg = doc(&format!(r#"{uses}"#, data("image/png", &png))); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 64); + assert!(shown.iter().all(|b| Arc::ptr_eq(b, &shown[0]) && **b == png)); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64); + assert!(files.left_out().is_empty(), "{:?}", files.left_out()); + } + + /// The data URLs inside an SVG image get keys too: each is decoded once, together with the + /// SVG image's own URL. + #[test] + fn data_urls_in_svg_images_are_decoded_once() { + let png = png(); + let uses = r##""##.repeat(16); + let inner = doc(&format!(r#"{uses}"#, data("image/png", &png))); + let svg = doc(&format!(r#""#, data("image/svg+xml", inner.as_bytes()))); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 16); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), (inner.len() + png.len()) as u64); + } + + /// An ``'s data URL gets a key too. + #[test] + fn fe_image_data_urls_are_decoded_once() { + let png = png(); + let rects: String = (1..=8).map(|i| format!(r#""#)).collect(); + let svg = doc(&format!(r#"{rects}"#, data("image/png", &png))); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 8); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64); + } + + /// Both hrefs get keys, and usvg still takes the plain `href` over `xlink:href`, in either + /// order: only its URL is decoded. + #[test] + fn the_plain_href_still_wins_over_xlink_href() { + let (one, two) = (png_of(1), png_of(2)); + let (one_url, two_url) = (data("image/png", &one), data("image/png", &two)); + for image in [ + format!(r#""#), + format!(r#""#), + ] { + let (files, shown) = read_keyed(&doc(&image), 0); + assert_eq!(shown.len(), 1); + assert_eq!(*shown[0], one); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), one.len() as u64); + } + } + + /// An image whose data URL is written in an entity, and so in every element the entity + /// makes, gets a key too. + #[test] + fn data_urls_written_in_entities_are_decoded_once() { + let png = png(); + let uses = r##""##.repeat(16); + let svg = format!( + r#"">]>{}"#, + data("image/png", &png), + doc(&format!("&img;{uses}")) + ); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 16); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64); + } + + /// The data URLs decode to at most [`MAX_DATA_URL_BYTES`] together: a URL that fits what is + /// left is decoded, and decoding stops where a URL would go past it, that image left out and + /// noted. + #[test] + fn data_urls_past_the_limit_are_left_out() { + let (small, big) = (png(), png_of(64)); + assert!(small.len() < big.len()); + let svg = doc(&format!( + r#""#, + data("image/png", &small), + data("image/png", &big) + )); + let spent = MAX_DATA_URL_BYTES - (small.len() + big.len()) as u64 + 1; + let (files, shown) = read_keyed(&svg, spent); + assert_eq!(shown.len(), 1); + assert_eq!(*shown[0], small); + assert!(files.data_bytes.load(Ordering::Relaxed) <= MAX_DATA_URL_BYTES); + assert_eq!(files.left_out(), [format!("images were left out: together their data URLs decode to more than {} MB", MAX_DATA_URL_BYTES >> 20)]); + // A data URL usvg decodes itself counts too. + let files = Files::default(); + files.data_bytes.store(MAX_DATA_URL_BYTES, Ordering::Relaxed); + let resolver = files.resolver(1); + assert!((resolver.resolve_data)("image/png", Arc::new(small.clone()), &usvg::Options::default()).is_none()); + drop(resolver); + assert_eq!(files.left_out().len(), 1); + } + + /// A link that looks like a key, spelled plainly or with character references, in the + /// imported SVG or in an SVG image, stays that link: it never shows a data URL's image. + #[test] + fn links_that_look_like_keys_stay_links() { + let png = png(); + let url = data("image/png", &png); + let inner = doc(&format!(r#""#)); + let svg = doc(&format!( + r#""#, + &DATA_URL_KEY[1..], + data("image/svg+xml", inner.as_bytes()) + )); + let (files, shown) = read_keyed(&svg, 0); + // The data URL's image, then the placeholder of the missing file, twice; the SVG image's + // link names no file there, so it shows nothing. + assert_eq!(shown.len(), 3); + assert_eq!(*shown[0], png); + assert!(shown[1..].iter().all(|b| **b != png && files.links.get(&key(b)).is_some_and(|l| l.missing))); + assert_eq!(files.data_number(&format!("{DATA_URL_KEY}00")), None); + assert_eq!(files.data_number(&format!("{DATA_URL_KEY}+0")), None); + assert_eq!(files.data_number(&format!("{DATA_URL_KEY}9")), None); + } + /// The nested SVG images read, decoded or not, take at most [`MAX_NESTED_SVG_TEXT`] bytes of /// text together: an image whose text goes past it is left out and noted. A broken image's /// text counts too, and its decoded place is given back. diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs index b760b1b119..4a549552d4 100644 --- a/crates/svg/src/import/fx.rs +++ b/crates/svg/src/import/fx.rs @@ -61,8 +61,10 @@ impl Source { /// Each `` element with an id, as written, by id, with the document's style sheets read /// once for all of them. A source is checked and read by usvg only when art uses it, once. -/// `markers` is the text with its data URLs left out ([`super::depth::prescan`]). -pub(super) fn sources(xml: &roxmltree::Document, markers: &str) -> HashMap { +/// `markers` is the text with its data URLs left out ([`super::depth::prescan`]). `href` gives the +/// href, as written, of each key the prepass wrote in place of an `` href +/// ([`super::files::Files::data_href`]): the source holds that href. +pub(super) fn sources(xml: &roxmltree::Document, markers: &str, href: impl Fn(&str) -> Option>) -> HashMap { if !markers.contains(" HashMap` href that `href` gives a written href for put back as written, XML-escaped. +fn hrefs_as_written(n: roxmltree::Node, mut xml: String, href: &impl Fn(&str) -> Option>) -> String { + // `element_xml` adds namespace declarations to the start tag only, ahead of every child. + let (start, end) = (n.range().start, n.range().end); + let Some(shift) = xml.len().checked_sub(end - start) else { return xml }; + let mut written: Vec<_> = n + .descendants() + .filter(|p| p.is_element() && p.tag_name().name() == "feImage") + .flat_map(|p| p.attributes()) + .filter(|a| a.name() == "href" && matches!(a.namespace(), None | Some("http://www.w3.org/1999/xlink"))) + .filter_map(|a| { + let r = a.range_value(); + // A value written through an entity lies outside the element's text: it stays. + (r.start > start && r.end < end).then_some(())?; + Some((r.start - start + shift..r.end - start + shift, a.value(), href(a.value())?)) + }) + .collect(); + written.sort_by_key(|(r, ..)| std::cmp::Reverse(r.start)); + for (r, key, h) in written { + if xml.get(r.clone()) == Some(key) { + xml.replace_range(r, &crate::xml_escape(&h)); + } + } + xml +} + /// The element name of primitive `k` (`feGaussianBlur`). fn element_name(k: &Kind) -> String { let debug = format!("{k:?}"); diff --git a/crates/svg/src/import/prepass.rs b/crates/svg/src/import/prepass.rs index 3d1b04ad60..e541963561 100644 --- a/crates/svg/src/import/prepass.rs +++ b/crates/svg/src/import/prepass.rs @@ -95,6 +95,11 @@ impl Edits { } } + /// Set the value of attribute `a` to `value` (XML-escaped already). + pub(crate) fn set_value(&mut self, a: roxmltree::Attribute, value: &str) { + self.list.push((a.range_value(), value.to_string())); + } + /// Add the declaration `decl` (XML-escaped already) to the `style` of element `n` (in `svg`), /// after the ones already there (so it wins over them and over style sheet rules). fn style(&mut self, svg: &str, n: XNode, decl: &str) { @@ -163,7 +168,8 @@ fn named_made_up_ids(xml: &roxmltree::Document) -> HashSet { /// * so do `` and `` elements, recorded: usvg makes their arcs into a varying /// number of curves, and they come back as the ellipse VectorCraft draws (not in a file whose /// style sheet has an attribute selector); -/// * linked files are read ([`files`]); +/// * linked files are read, and the data URLs of `` and `` elements are each +/// changed to a key that the resolver decodes once ([`files`]); /// * percentage lengths in a `` with a `viewBox` become numbers ([`symbol_units`]); /// * what style sheet rules with a `:not()` set goes in `style` attributes ([`not_rules`]). /// @@ -190,7 +196,7 @@ pub(super) fn prepass<'s>( && !events && !foreign_attrs && !ellipses - && ![" 0.0 { advance(&point_text(&cells)) } else { 0.0 }; // On a path of one closed subpath, text that starts before the path's start or past its - // end wraps around the path. Otherwise it starts at the path's start or end. + // end wraps around the path. On an open path text may start before the path's start, + // and its characters before the start are not drawn; text that starts past the end + // starts at the end. Other paths start it at the path's start or end. let path = PathData::from_bezpath(&bp); let wraps = path.subpaths.len() == 1 && path.is_closed(); + let open = !path.is_closed(); let wrap = |s: f64| { // `rem_euclid` can round a tiny negative value up to 1. let s = s.rem_euclid(1.0); if s < 1.0 { s } else { 0.0 } }; - let start = - Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| if wraps { wrap(s) } else { s.clamp(0.0, 1.0) }); + let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| { + if wraps { + wrap(s) + } else if open { + s.clamp(-vectorcraft_doc::PATH_LEAD_MAX, 1.0) + } else { + s.clamp(0.0, 1.0) + } + }); obj.kind = TextKind::OnPath { path, start, end: None }; obj.xf = Affine::IDENTITY; } else if vertical { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 3272c999fc..6600f3010a 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -533,7 +533,10 @@ pub fn svg_filters(svg: &str, room: usize, taken: impl Fn(&str) -> bool) -> Resu } pub use glyph::glyph_art; -pub use import::{MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT, MAX_NODES}; +pub use import::{ + MAX_DATA_URL_BYTES, MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT, + MAX_NODES, +}; /// Import an SVG document reading the files it links to as `opts` says, also returning warnings. pub fn import_with(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec), SvgError> { diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 4d6606eaa5..3a598f5ca1 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -949,6 +949,21 @@ fn nested_svg_images_past_count_cap_are_left_out() { assert!(warnings.iter().any(|w| w.contains(&format!("more than {MAX_NESTED_SVG_IMAGES} nested SVG images")))); } +/// A data URL shown by `` elements, in the SVG and in an SVG image of it, imports as one +/// image shown at each use. +#[test] +fn data_url_images_shown_by_uses_import_at_each_use() { + let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=="; + let image = format!(r#""#); + let uses = r##""##.repeat(3); + let inner = format!(r#"{image}{uses}"#); + let (d, w) = import_with_report(&svg(&format!("{image}{uses}{}", image_of("image/svg+xml", &inner)))).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let found = images(&d); + assert_eq!(found.len(), 6); + assert!(found.iter().all(|(_, im)| im.key == found[0].1.key && (im.width, im.height) == (1, 1))); +} + /// Nested SVG images that can't be read: text that isn't XML, and an SVG without a size. const BROKEN_SVG_IMAGES: [&str; 2] = [r#""#]; diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 816e17f15d..72b476eb24 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -335,10 +335,12 @@ fn text_on_a_closed_path_wraps_past_the_path_start() { // A negative startOffset, in user units or as a percentage, counts back from the path's end. assert!(close(start_of(t[4]), 0.9, 1e-9), "{}", start_of(t[4])); assert!(close(start_of(t[5]), 0.9, 1e-9), "{}", start_of(t[5])); - // A path of two closed subpaths, and an open path, do not wrap: the text starts at the path's - // start or end. + // A path of two closed subpaths does not wrap: the text starts at the path's start. An open + // path does not wrap either: text anchored before its start starts there + // (`text_on_an_open_path_starts_before_the_path`), and text offset past its end starts at + // its end. assert_eq!(start_of(t[6]), 0.0); - assert_eq!(start_of(t[7]), 0.0); + assert!(close(start_of(t[7]), -width / 300.0, 1e-6), "{} {width}", start_of(t[7])); assert_eq!(start_of(t[8]), 1.0); // Laid out, the end-anchored text draws every glyph up the left side and ends at the start @@ -372,6 +374,52 @@ fn text_on_a_closed_path_wraps_past_the_path_start() { assert!(lay.glyphs.iter().enumerate().all(|(i, g)| g.byte == i), "{:?}", lay.glyphs.iter().map(|g| g.byte).collect::>()); } +#[test] +fn text_on_an_open_path_starts_before_the_path() { + // An open path 300 long. Text anchored, or offset, before its start starts that far before it, + // and only the characters whose middle is on the path are drawn; the text keeps every character. + let d = open( + r##" + Wave + Wave + Wave + Wave"##, + ); + let t = texts(&d); + assert_eq!(t.len(), 4); + let start_of = |t: &TextObject| match t.kind { + TextKind::OnPath { start, .. } => start, + _ => panic!("{:?}", t.kind), + }; + let whole = layout(FontDb::global(), t[3]); + let width: f64 = whole.glyphs.iter().map(|g| g.advance).sum(); + assert!(width > 0.0 && width < 60.0, "{width}"); + assert!(close(start_of(t[0]), -width / 300.0, 1e-6), "{} {width}", start_of(t[0])); + assert!(close(start_of(t[1]), -width / 600.0, 1e-6), "{} {width}", start_of(t[1])); + assert!(close(start_of(t[2]), -0.2, 1e-9), "{}", start_of(t[2])); + let drawn = |t: &TextObject| { + let lay = layout(FontDb::global(), t); + assert!(!lay.overflow && lay.glyphs.len() == 4, "{} {}", lay.overflow, lay.glyphs.len()); + assert_eq!(t.plain_text(), "Wave"); + lay.glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect::>() + }; + // Ending at the path's start: nothing is drawn. + assert_eq!(drawn(t[0]), [false; 4]); + // Centered on the path's start: the characters whose middle is past the start are drawn, the + // last ones of the text. + // (A hidden glyph has no advance: the advances are the ones the text takes on the path.) + let middle = drawn(t[1]); + let mut x = -width / 2.0; + for (g, shown) in whole.glyphs.iter().zip(&middle) { + assert_eq!(*shown, x + g.advance / 2.0 >= 0.0, "{x} {middle:?}"); + x += g.advance; + } + assert!(middle.iter().any(|d| *d) && !middle[0] && middle[3], "{middle:?}"); + // 60 before the start, more than the text's width: nothing is drawn. + assert_eq!(drawn(t[2]), [false; 4]); + assert_eq!(drawn(t[3]), [true; 4]); +} + /// The unit vector along a glyph's baseline. fn baseline_dir(angle: f64) -> vectorcraft_geom::Vec2 { vectorcraft_geom::Vec2::new(angle.cos(), angle.sin()) diff --git a/crates/svg/tests/it/roundtrip.rs b/crates/svg/tests/it/roundtrip.rs index 127e47846c..0846d694f2 100644 --- a/crates/svg/tests/it/roundtrip.rs +++ b/crates/svg/tests/it/roundtrip.rs @@ -478,6 +478,31 @@ fn aligned_text_on_path_anchors_within_its_span() { } } +/// Type on an open path that starts before the path is written with a negative `startOffset` and +/// reads back starting there, with the same characters drawn. +#[test] +fn text_on_an_open_path_starting_before_it_round_trips() { + let mut line = vectorcraft_geom::BezPath::new(); + line.move_to((0.0, 100.0)); + line.line_to((200.0, 100.0)); + let mut d = Document::new(200.0, 200.0); + let mut t = TextObject::point(Point::ZERO, "Before", CharStyle { size: 14.0, ..CharStyle::default() }); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.1, end: None }; + let shown = |t: &TextObject| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect::>() + }; + let before = shown(&t); + assert!(before.contains(&true) && before.contains(&false), "{before:?}"); + let id = d.alloc_id(); + let svg = export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &ExportOptions::default()); + assert!(svg.contains("startOffset=\"-10%\""), "{svg}"); + let r = import(&svg).unwrap(); + let NodeKind::Text(t) = &art(&r)[0].kind else { panic!() }; + let TextKind::OnPath { start, .. } = &t.kind else { panic!("not type on a path: {:?}", t.kind) }; + assert!((start + 0.1).abs() < 1e-6, "start {start}"); + assert_eq!(shown(t), before); +} + /// An end bracket, Align to Path or Spacing, which `` can't express, keep their look as /// outlines (#429). #[test] diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 4aabae7b1a..11f174e1a4 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -295,7 +295,8 @@ impl OtFeatures { let own = self.with_tags(listed.iter().map(String::as_str)); let s = &own; let mut v = Vec::with_capacity(8); - if st.kerning.is_some() { + // A monospaced width form turns the font's pair kerning off as well. + if st.kerning.is_some() || st.glyph_form.is_width() { v.push(f(b"kern", false)); } let liga = s.ligatures && (!ligatures_suppressed_by(st.tracking) || explicit_ligatures(listed)); @@ -342,15 +343,20 @@ impl OtFeatures { // Kerning for the glyphs `palt` makes proportional (OpenType `apkn`), with automatic // kerning only. v.push(f(b"apkn", st.kerning.is_none())); + } else if st.glyph_form.is_width() { + // Listed as off, not left out: in runs shaped together with proportional text, a + // feature left out is set back to the shaper's default, which for `apkn` is on. + v.push(f(b"apkn", false)); } v } /// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in - /// horizontal type; vertical type takes proportional heights instead + /// horizontal type, except with a monospaced width form ([`vectorcraft_doc::GlyphForm::is_width`]), + /// which sets the characters on a fixed width; vertical type takes proportional heights instead /// ([`Self::proportional_vertical`]). pub(crate) fn proportional(&self, st: &CharStyle) -> bool { - st.proportional_metrics && !self.vertical + st.proportional_metrics && !self.vertical && !st.glyph_form.is_width() } /// Does text in style `st` take proportional heights (`vpal`)? Proportional Metrics in @@ -630,4 +636,44 @@ mod tests { assert!(serbian.iter().zip(&russian).all(|(s, r)| s != r), "every letter in its Serbian form"); assert_eq!(outlines("sr-Latn"), serbian); } + + /// A monospaced width form (`fwid`, `hwid`, `twid`, `qwid`) and Proportional Metrics' `palt` + /// are mutually exclusive: with a width form `palt` is left out and the font's pair kerning + /// (`kern`, and `apkn` for `palt`'s glyphs) is turned off, with automatic or manual kerning, + /// and with the other forms they stay. + /// + #[test] + fn width_forms_turn_off_proportional_metrics() { + use vectorcraft_doc::GlyphForm; + let tags = |form, kerning, value| { + let st = CharStyle { proportional_metrics: true, glyph_form: form, kerning, ..CharStyle::default() }; + OtFeatures::default().resolve(&st).iter().filter(|f| f.value == value).map(|f| f.tag.to_string()).collect::>() + }; + for form in [GlyphForm::Default, GlyphForm::Traditional, GlyphForm::Jis78] { + assert!(tags(form, None, 1).contains(&"palt".to_string()), "{form:?}: {:?}", tags(form, None, 1)); + assert!(!tags(form, None, 0).contains(&"kern".to_string()), "{form:?}: {:?}", tags(form, None, 0)); + } + for form in [GlyphForm::FullWidth, GlyphForm::HalfWidth, GlyphForm::ThirdWidth, GlyphForm::QuarterWidth] { + for kerning in [None, Some(0.0)] { + let on = tags(form, kerning, 1); + assert!(!on.contains(&"palt".to_string()) && !on.contains(&"apkn".to_string()), "{form:?}: {on:?}"); + assert!(on.iter().any(|t| t.as_bytes() == form.feature().unwrap()), "{form:?}: {on:?}"); + // Listed as off, so that runs shaped together with proportional text keep them off. + let off = tags(form, kerning, 0); + assert!(off.contains(&"kern".to_string()) && off.contains(&"apkn".to_string()), "{form:?} {kerning:?}: {off:?}"); + } + } + } + + /// A face's `GPOS` features are found apart from its `GSUB` ones: Source Sans 3 kerns with + /// `GPOS` and makes ligatures with `GSUB`. + #[test] + fn positioning_and_substitution_features_are_found_in_their_own_tables() { + let face = FontDb::global().face("Source Sans 3", "Regular").unwrap(); + assert!(face.has_positioning_feature(*b"kern")); + assert!(!face.has_substitution_feature(*b"kern")); + assert!(face.has_substitution_feature(*b"liga")); + assert!(!face.has_positioning_feature(*b"liga")); + assert!(!face.has_positioning_feature(*b"fwid")); + } } diff --git a/crates/text/src/fontdb/gsub.rs b/crates/text/src/fontdb/gsub.rs index bcc19f74b0..a245efeea2 100644 --- a/crates/text/src/fontdb/gsub.rs +++ b/crates/text/src/fontdb/gsub.rs @@ -158,6 +158,17 @@ fn ligature_glyphs(t: &LigatureSubstFormat1<'_>, room: usize) -> Vec { out } +/// Does `features` (a `GSUB` or `GPOS` feature list) have feature `tag` with at least one lookup? +/// At most [`MAX_FEATURES`] feature records are read. +fn has_feature(features: &skrifa::raw::tables::layout::FeatureList<'_>, tag: [u8; 4]) -> bool { + features + .feature_records() + .iter() + .take(MAX_FEATURES) + .filter(|r| r.feature_tag().to_be_bytes() == tag) + .any(|r| r.feature(features.offset_data()).is_ok_and(|f| !f.lookup_list_indices().is_empty())) +} + impl GsubIndex { /// Read `face`'s `GSUB` table. Empty when the face has none or it cannot be read; glyph ids past /// the face's glyph count are left out; the caps above bound what a damaged table makes it read. @@ -309,13 +320,15 @@ impl FontFace { /// cannot be read. pub fn has_substitution_feature(&self, tag: [u8; 4]) -> bool { let Some(font) = self.skrifa() else { return false }; - let Some(features) = font.gsub().ok().and_then(|g| g.feature_list().ok()) else { return false }; - features - .feature_records() - .iter() - .take(MAX_FEATURES) - .filter(|r| r.feature_tag().to_be_bytes() == tag) - .any(|r| r.feature(features.offset_data()).is_ok_and(|f| !f.lookup_list_indices().is_empty())) + font.gsub().ok().and_then(|g| g.feature_list().ok()).is_some_and(|features| has_feature(&features, tag)) + } + + /// Does the face's `GPOS` table have feature `tag` with at least one lookup? At most + /// [`MAX_FEATURES`] feature records are read; false when the face has no `GPOS` table or it + /// cannot be read. + pub fn has_positioning_feature(&self, tag: [u8; 4]) -> bool { + let Some(font) = self.skrifa() else { return false }; + font.gpos().ok().and_then(|g| g.feature_list().ok()).is_some_and(|features| has_feature(&features, tag)) } /// What the features `aalt`, `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index d7669f2fe2..6f558d86dd 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -626,6 +626,7 @@ impl Ctx<'_> { width_start, warichu: g.wari.map(|w| w.start), warichu_line: g.wari.map_or(0.0, |w| w.across), + off_path: false, }); } diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index d119f5d780..d1072d3781 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -380,6 +380,35 @@ pub(crate) fn layout_trying_each_start(db: &crate::fontdb::FontDb, t: &TextObjec l } +/// For type that starts before its open path `ap` (`lead`), a glyph whose middle is `mid` along the +/// path and falls before the path's start is not drawn, as SVG 2 lays out text on a path +/// (https://www.w3.org/TR/SVG2/text.html#TextLayoutPath): glyph `at` keeps its place in the layout +/// for the caret, with no length at the path's start (moved off the path by `rise`, as Align to Path +/// moves the glyph), and draws nothing ([`crate::PositionedGlyph::off_path`]). True when it is hidden. +fn hide_before_open_path(cx: &mut Ctx<'_>, ap: &ArcPath, lead: bool, at: usize, mid: f64, rise: f64) -> bool { + if !lead || ap.is_closed() || mid >= 0.0 { + return false; + } + let (p, dir) = ap.at(0.0); + let start = p + Vec2::new(-dir.y, dir.x) * rise; + if let Some(glyph) = cx.out.glyphs.get_mut(at) { + glyph.origin = start; + glyph.advance = 0.0; + glyph.width = 0.0; + glyph.width_start = 0.0; + glyph.outline = BezPath::new(); + glyph.image = None; + glyph.colour = None; + // A ligature's caret stops stay, at the path's start with the glyph. + for c in &mut glyph.carets { + c.1 = 0.0; + } + glyph.off_path = true; + } + cx.out.inlines.retain(|i| i.glyph != at); + true +} + /// Lay type on a path out along `path` (text space), between its brackets. pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &PathData) { cx.out.on_path = true; @@ -453,6 +482,11 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, } let centre = path.bounds().unwrap_or_default().center(); let (from, to) = t.kind.path_span().unwrap_or((0.0, 1.0)); + // On an open path the text may start before the path's start: the span runs from there, so + // Dynamic Text fills it to the end bracket, and the drawn text runs from the path's start to the + // end bracket. + let from = t.kind.path_text_start().unwrap_or(from); + let lead = t.kind.starts_before_path(); let s_start = from * ap.len(); let avail = (to - from) * ap.len(); let spacing = if t.path_spacing.is_finite() { t.path_spacing } else { 0.0 }; @@ -491,7 +525,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, } } } else { - (l.min(s_start), r.min((ap.len() - s_start - avail).max(0.0))) + (l.min(s_start.max(0.0)), r.min((ap.len() - s_start - avail).max(0.0))) }; (l, r, hung_r) }; @@ -673,10 +707,12 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, m.tab = at; } } + let at = cx.out.glyphs.len(); cx.emit(g, pre, origin, angle, g.adv, 0); + let hidden = hide_before_open_path(cx, &ap, lead, at, s + step * 0.5, rise); // A glyph of a script whose letters join is bent along a curved path (Rainbow), so its // joining strokes meet its neighbours' instead of standing off the curve. - if t.path_effect == PathEffect::Rainbow && g.cursive && !cx.vertical && g.inline.is_none() { + if !hidden && t.path_effect == PathEffect::Rainbow && g.cursive && !cx.vertical && g.inline.is_none() { let bend = GlyphBend { s, step, advance: g.adv, rise }; if let Some(last) = cx.out.glyphs.last_mut() && last.image.is_none() diff --git a/crates/text/src/layout/ruby.rs b/crates/text/src/layout/ruby.rs index 9b8da39d80..f51b6e64b4 100644 --- a/crates/text/src/layout/ruby.rs +++ b/crates/text/src/layout/ruby.rs @@ -379,5 +379,6 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi width_start: 0.0, warichu: None, warichu_line: 0.0, + off_path: false, } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 362a1c2408..07d761926b 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -188,6 +188,12 @@ pub struct PositionedGlyph { /// line's baseline before Character Alignment moves the glyph (y down). It names the block /// line whatever the alignment of each character on it. 0 for any other glyph. pub warichu_line: f64, + /// Type on an open path that starts before the path: the glyph's middle falls before the + /// path's start, so it draws nothing (its `outline` is empty and it has no image or color + /// art), hit testing and Touch Type skip it while a glyph is drawn, and exports leave it out. + /// It stays in the layout for the caret, at the path's start with no advance or width, and its + /// ligature carets stand there too. + pub off_path: bool, } /// How a glyph on a path is bent along it: a point of its pen space (x along its advance from its @@ -600,11 +606,15 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { .map(|(_, d)| (p - (g.origin + along * if g.rtl { g.advance - d } else { d })).hypot2()) .fold(mid, f64::min) }; - let best = layout.glyphs.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))); + // Glyphs before the start of an open path draw nothing: a click goes to a drawn one when + // there is one. + let drawn = layout.glyphs.iter().any(|g| !g.off_path); + let best = layout.glyphs.iter().enumerate().filter(|(_, g)| !(drawn && g.off_path)).min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))); let Some((i, g)) = best else { return layout.lines.first().map_or(0, |l| l.start) }; // `first` and `last` are the first and last glyphs of the cluster. A click before the middle of - // the cluster, measured along the first glyph's direction, goes to the cluster's start. - let same = |o: &&PositionedGlyph| o.byte == g.byte && o.len == g.len; + // the cluster, measured along the first glyph's direction, goes to the cluster's start. A + // cluster's glyphs before an open path's start are left out when one of its glyphs is drawn. + let same = |o: &&PositionedGlyph| o.byte == g.byte && o.len == g.len && !(drawn && o.off_path); let first = layout.glyphs[..=i].iter().rev().take_while(same).last().unwrap_or(g); let last = layout.glyphs[i..].iter().take_while(same).last().unwrap_or(g); let along = dir(first.angle); diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 9d36eed28b..4c320a2bb4 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -1545,7 +1545,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe char_level.set(false); *chars = crate::harakat::own_chars(raw, &by_char, range.end, text, |gid| classes.get(skrifa::GlyphId::new(gid)) == 3); } - if feats.proportional(st) { + // Runs shaped together may differ in their glyph form, and a width form turns `palt` off. + if feats.proportional(st) || runs.get(source_runs.clone()).unwrap_or_default().iter().any(|(_, s)| feats.proportional(s)) { // `palt` is positioning only: the same glyphs set on their full widths tell which // advances it changed (kerning and the rest change both alike). let full = shape(&with_more(&|s| feats.resolve_fixed_width(s)), along); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 423984a630..3837a1adf4 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -635,6 +635,85 @@ fn on_path_placement() { assert_eq!(hit_byte(&l, mid), 1); } +/// Type on an open path that starts before the path's start draws only the glyphs whose middle is +/// on the path; the others stay in the layout, drawing nothing. On a closed path a start below 0 is +/// the path's start, as before. +#[test] +fn on_path_open_start_before_the_path_hides_the_glyphs_before_it() { + let mut line = BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + let mut t = point("MMMM", style(20.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let whole = layout(db(), &t); + let adv = whole.glyphs[0].advance; + assert!(adv > 1.0 && whole.glyphs.iter().all(|g| !g.outline.elements().is_empty())); + // 2.4 glyphs before the start: the first two are before it, and the third's middle is just past + // the path's start, so it is drawn. + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -2.4 * adv / 500.0, end: None }; + let l = layout(db(), &t); + assert!(l.on_path && !l.overflow); + assert_eq!(l.glyphs.len(), 4); + let drawn: Vec = l.glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect(); + assert_eq!(drawn, [false, false, true, true]); + assert_eq!(l.glyphs.iter().map(|g| g.off_path).collect::>(), [true, true, false, false]); + assert!(l.glyphs[..2].iter().all(|g| g.carets.iter().all(|c| c.1 == 0.0) && g.advance == 0.0 && g.width == 0.0)); + assert!(whole.glyphs.iter().all(|g| !g.off_path)); + // Type that starts on the path keeps drawing every glyph, even where negative tracking steps + // its glyphs back before the path's start. + let mut packed = t.clone(); + packed.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + packed.runs[0].style.tracking = -1000.0; + let p = layout(db(), &packed); + assert!(p.glyphs.len() == 4 && p.glyphs.iter().all(|g| !g.off_path && !g.outline.elements().is_empty())); + assert!((l.glyphs[2].origin.x + 0.4 * adv).abs() < 1e-6, "{:?}", l.glyphs[2].origin); + assert!((l.glyphs[3].origin.x - 0.6 * adv).abs() < 1e-6, "{:?}", l.glyphs[3].origin); + assert_eq!(l.lines[0].end, 4); + // A click at the path's start goes before the first drawn character, not into a hidden one, and + // the caret in the hidden characters stands at the path's start. + assert_eq!(hit_byte(&l, Point::new(0.0, 50.0)), 2); + for b in [0, 1] { + let (top, bottom) = caret_position(&l, b); + assert!(top.x.abs() < 1e-6 && bottom.x.abs() < 1e-6, "{b}: {top:?} {bottom:?}"); + } + // An All Caps ß is two glyphs. With the first before the path's start, a click is measured + // against the drawn one: just past its middle, the caret goes after the character. + let mut caps = point("ße", style(20.0)); + caps.runs[0].style.all_caps = true; + caps.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let s = layout(db(), &caps).glyphs[0].advance; + caps.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -1.2 * s / 500.0, end: None }; + let c = layout(db(), &caps); + assert!(c.glyphs[0].byte == 0 && c.glyphs[1].byte == 0 && c.glyphs[0].off_path && !c.glyphs[1].off_path); + let mid = c.glyphs[1].origin.x + c.glyphs[1].advance / 2.0; + assert_eq!(hit_byte(&c, Point::new(mid + 0.05 * s, 50.0)), 2); + assert_eq!(hit_byte(&c, Point::new(mid - 0.05 * s, 50.0)), 0); + // With Align to Path, the hidden characters' caret is moved off the path as the drawn ones are. + let mut aligned = t.clone(); + aligned.path_align = vectorcraft_doc::PathAlign::Ascender; + let a = layout(db(), &aligned); + let rise = a.glyphs[2].origin.y - 50.0; + assert!(rise.abs() > 1.0, "{rise}"); + for g in &a.glyphs[..2] { + assert!(g.off_path && g.origin.x.abs() < 1e-6 && (g.origin.y - 50.0 - rise).abs() < 1e-6, "{:?}", g.origin); + } + // The whole text before the path: nothing is drawn, and nothing overflows. + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.9, end: None }; + let l = layout(db(), &t); + assert!(!l.overflow && l.glyphs.len() == 4 && l.glyphs.iter().all(|g| g.outline.elements().is_empty())); + // A closed path starts the text at the path's start. + let mut square = BezPath::new(); + square.move_to((0.0, 0.0)); + square.line_to((200.0, 0.0)); + square.line_to((200.0, 200.0)); + square.line_to((0.0, 200.0)); + square.close_path(); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&square), start: -0.1, end: None }; + let l = layout(db(), &t); + assert!(l.glyphs.iter().all(|g| !g.outline.elements().is_empty())); + assert!(l.glyphs[0].origin.x.abs() < 1e-6 && l.glyphs[0].origin.y.abs() < 1e-6, "{:?}", l.glyphs[0].origin); +} + #[test] fn on_path_effects_orient_glyphs() { use vectorcraft_doc::PathEffect; diff --git a/crates/text/src/tests/bidi.rs b/crates/text/src/tests/bidi.rs index f02db481ab..a8f035aa6e 100644 --- a/crates/text/src/tests/bidi.rs +++ b/crates/text/src/tests/bidi.rs @@ -170,6 +170,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout width_start: 0.0, warichu: None, warichu_line: 0.0, + off_path: false, }); x += advance; } diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index c8d93d451f..0b401a5cd3 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -649,6 +649,7 @@ fn duplicate_authored_glyphs_keep_occurrence_order() { width_start: 0.0, warichu: None, warichu_line: 0.0, + off_path: false, }; let before = TextLayout { glyphs: vec![glyph(0), glyph(1)], ..TextLayout::default() }; assert_eq!(assert_kashida_ownership(&text, &before, &before, &red(), "duplicate glyphs", false), 0); diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index d85ff4fffa..46e77314b6 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -14,7 +14,7 @@ use vectorcraft_geom::{Affine, Anchor, AnchorKind, BezPath, PathData, Point, Sub use vectorcraft_pathops as po; use vectorcraft_pathops::{Cap, Join}; -use crate::{Action, Tool, ToolContext}; +use crate::{Action, PenPose, Tool, ToolContext}; pub use anchor::AnchorTool; pub use curvature::{ @@ -87,6 +87,34 @@ pub fn pressure_size(size: f64, variation: f64, pressure: f64) -> f64 { /// The narrowest a Blob Brush or Eraser stroke gets, in points (their Size field's minimum). pub const MIN_BRUSH_SIZE: f64 = 0.1; +/// A pen's tilt as a lean on the page (each tilt's tangent) and its angle from upright in degrees, +/// as Pointer Events' tiltX and tiltY convert to an altitude and an azimuth. +fn pen_lean(tilt: [f32; 2]) -> ([f64; 2], f64) { + let [x, y] = tilt.map(|d| f64::from(d).to_radians().tan()); + ([x, y], x.hypot(y).atan().to_degrees()) +} + +/// Which way pose `q` leans, in degrees counter-clockwise on the page from leaning to the right, +/// when it leans 1° or more from upright (`path.eraseRegion` keeps the bearing before a pen +/// nearer upright than that). +pub(crate) fn pen_bearing(q: PenPose) -> Option { + let ([x, y], from_upright) = pen_lean(q.tilt?); + (from_upright >= 1.0).then(|| (-y).atan2(x).to_degrees()) +} + +/// The 0..1 that each of the Eraser's variation modes Pressure, Tilt, Bearing and Rotation reads +/// from a pen at `pressure` (0..1) held as `pose`, leaning toward `bearing` (degrees, from +/// [`pen_bearing`]): 0 gives the value less its variation and 1 the value plus it, as +/// `path.eraseRegion` reads a stroke's points. Tilt reads 1 upright and 0 lying flat; Bearing and +/// Rotation read ½ plus the angle over 360°, the rotation turned counter-clockwise. What the pen +/// does not give reads ½, the value itself. +pub(crate) fn pen_controls(pressure: f64, pose: Option, bearing: Option) -> [f64; 4] { + let tilt = pose.and_then(|q| q.tilt).map_or(0.5, |t| 1.0 - pen_lean(t).1 / 90.0); + let rotation = pose.and_then(|q| q.rotation).map_or(0.5, |r| 0.5 + vectorcraft_geom::normalize_deg(-f64::from(r)) / 360.0); + let bearing = bearing.map_or(0.5, |b| 0.5 + b / 360.0); + [pressure, tilt, bearing, rotation].map(|c| if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.5 }) +} + /// Create a drawing tool by id (None = not one of ours). pub fn create(id: &str) -> Option> { Some(match id { diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 562654c3c4..75b71ec8df 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -12,7 +12,10 @@ //! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes, and //! the pen's tilt and barrel rotation where the pen reads them (`[x, y, pressure, tiltX, tiltY, //! rotation]` points, `null` for what a point lacks) for brushes that vary with them. The Blob -//! Brush and Eraser also record pressure when their Size varies with Pressure. The other tools +//! Brush and Eraser also record pressure when their Size varies with Pressure, and the Eraser +//! records the pressure, tilt and rotation that its Angle, Roundness and Size variations follow; +//! while it erases, its tip outline at the pointer takes the angle, roundness and size the pen +//! gives there. The other tools //! collect the drag polyline (shown as an overlay) and run one command on release. With Shift //! held, the Blob Brush's points stay on the horizontal or //! vertical line through the press point. Alt held at the press, or before the Blob Brush's drag @@ -29,6 +32,7 @@ use std::cell::Cell; use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; +use vectorcraft_brush::{Calligraphic, Variation}; use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::{Affine, BezPath, FillRule, ParamCurve, PathData, PathSeg, Point, Rect, Vec2, shapes}; use vectorcraft_pathops as po; @@ -88,10 +92,11 @@ pub struct GestureTool { previewed: usize, id: &'static str, points: Vec, - /// The pen pressure of each of `points` (the Paintbrush, and the Blob Brush and the Eraser - /// with `size_pressure`). + /// The pen pressure of each of `points` (the Paintbrush, the Blob Brush with Size on Pressure, + /// and the Eraser with any of its variations on Pressure). pressures: Vec, - /// The pen's tilt and rotation at each of `points` (the Paintbrush only). + /// The pen's tilt and rotation at each of `points` (the Paintbrush, and the Eraser with any of + /// its variations on Tilt, Bearing or Rotation). poses: Vec>, active: bool, began: bool, @@ -117,16 +122,22 @@ pub struct GestureTool { pub fidelity: f64, /// Brush / eraser diameter in points (the Eraser's across its tip's long axis). pub size: f64, - /// Blob Brush and Eraser Tool Options' Size set to Pressure (else Fixed): the tip is `size` − - /// `size_variation` wide at the lightest pen pressure and `size` + `size_variation` at the - /// heaviest. - pub size_pressure: bool, + /// Blob Brush and Eraser Tool Options' Size mode: Fixed or Pressure for the Blob Brush (with + /// Pressure the tip is `size` − `size_variation` wide at the lightest pen pressure and `size` + + /// `size_variation` at the heaviest), and any [`Variation`] for the Eraser. + pub size_mode: Variation, /// Blob Brush and Eraser Tool Options' Size Variation, in points. pub size_variation: f64, /// The angle of the Blob Brush or Eraser tip's long axis, in degrees counter-clockwise from horizontal. pub angle: f64, /// The Blob Brush or Eraser tip's short axis in percent of its long one (100: round). pub roundness: f64, + /// Eraser Tool Options' Angle mode and its Variation in degrees (0 to 180). + pub angle_mode: Variation, + pub angle_variation: f64, + /// Eraser Tool Options' Roundness mode and its Variation in percent (0 to 100). + pub roundness_mode: Variation, + pub roundness_variation: f64, /// Fill new pencil/brush strokes. pub fill: bool, /// "Keep selected": select the path a Pencil, Paintbrush or Blob Brush stroke draws, continues @@ -176,6 +187,9 @@ pub struct GestureTool { tip_at: Option, /// A pen pressed less than fully at a point of the stroke whose pressure is kept. light: bool, + /// Which way the pen leaned at the last point of the stroke where it leaned 1° or more from + /// upright ([`super::pen_bearing`]), for the Eraser's tip outline. + bearing: Option, /// The length of the stroke, in points. length: f64, /// The outline of the area a Blob Brush or Eraser drag covers, as far as it is built @@ -829,10 +843,14 @@ impl GestureTool { redraw: None, fidelity, size: 10.0, - size_pressure: false, + size_mode: Variation::Fixed, size_variation: 0.0, angle: 0.0, roundness: 100.0, + angle_mode: Variation::Fixed, + angle_variation: 0.0, + roundness_mode: Variation::Fixed, + roundness_variation: 0.0, fill: false, keep_selected: id != "blobBrush", // Off, as Pencil Tool Options ships it: Alt keeps drawing a closed path until it is on. @@ -852,6 +870,7 @@ impl GestureTool { pressed: Point::ZERO, tip_at: None, light: false, + bearing: None, length: 0.0, area: Cell::new(None), area_pending: Cell::new(false), @@ -908,15 +927,40 @@ impl GestureTool { self.length += last.distance(p); } self.points.push(p); - if self.id == "paintbrush" || (self.varies_size() && self.size_pressure) { + let modes = self.tip_modes(); + // The poses go out beside the pressures (`stroke_json`), so a stroke that keeps one keeps both. + let posed = self.id == "paintbrush" || modes.iter().any(|m| matches!(m, Variation::Tilt | Variation::Bearing | Variation::Rotation)); + if posed || modes.contains(&Variation::Pressure) { self.pressures.push(ev.pressure); self.light |= ev.pressure < 1.0; } - if self.id == "paintbrush" { - self.poses.push(ev.pen.map(PenPose::sanitized)); + if posed { + let pose = ev.pen.map(PenPose::sanitized); + self.bearing = pose.and_then(super::pen_bearing).or(self.bearing); + self.poses.push(pose); } } + /// Give the Eraser's last stroke sample the pen's tilt and rotation of the event `ev`, a drag + /// within a pixel of that sample that adds none: a pen turned or tilted in place turns its tip + /// there, in the outline and in what the release erases. The sample keeps its pressure, which + /// the area outline of the drag was built with, and its pose when `ev` reads none. + fn refresh_last_pose(&mut self, ev: &PointerEvent) { + if self.id != "eraser" || self.poses.len() != self.points.len() || ev.pen.is_none() { + return; + } + let pose = ev.pen.map(PenPose::sanitized); + let Some(last) = self.poses.last_mut() else { return }; + let had_bearing = last.and_then(super::pen_bearing).is_some(); + *last = pose; + // The bearing is that of the last sample the pen leaned at, as `push` keeps it. + self.bearing = match pose.and_then(super::pen_bearing) { + Some(b) => Some(b), + None if had_bearing => self.poses.iter().rev().find_map(|q| q.and_then(super::pen_bearing)), + None => self.bearing, + }; + } + /// Would a press with `m` held draw a straight Pencil line? Shift or Alt held, unless Alt lends /// the Smooth tool: the rule `PointerKind::Down` sets `straight` by. fn straight_at_press(&self, m: Mods) -> bool { @@ -975,12 +1019,14 @@ impl GestureTool { } /// The Blob Brush's or the Eraser's tip at the pointer, before and while painting: its outline - /// `size` across at its angle and roundness, at the zoom, black on a white halo so it reads on + /// `size` across at its angle and roundness (the Eraser's as the pen varies them while it + /// erases, [`Self::tip_now`]), at the zoom, black on a white halo so it reads on /// any art. Precise cursors show only the crosshair, and smoothing shows no tip. fn tip_outline(&self, cx: &ToolContext) -> Vec { let Some(at) = self.tip_at.filter(|_| !self.smoothing && !cx.precise_cursors) else { return vec![] }; - let tip = if self.id == "blobBrush" { blob_tip(self.angle, self.roundness) } else { eraser_tip(self.angle, self.roundness) }; - let r = self.size / 2.0; + let [angle, roundness, size] = self.tip_now(); + let tip = if self.id == "blobBrush" { blob_tip(angle, roundness) } else { eraser_tip(angle, roundness) }; + let r = size / 2.0; // A tolerance relative to the radius: four arcs at any size or zoom. let circle = kurbo::Shape::to_path(&kurbo::Circle::new(Point::ORIGIN, r), 0.1 * r.max(1e-6)); let path = Affine::translate(at.to_vec2()) * tip * circle; @@ -999,6 +1045,7 @@ impl GestureTool { self.previewed = 0; self.shift_line = None; self.light = false; + self.bearing = None; self.length = 0.0; self.area.set(None); self.area_pending.set(false); @@ -1064,7 +1111,53 @@ impl GestureTool { /// keeps its Size). fn size_pressures(&self) -> Option<&[f32]> { let pressed = self.pressures.len() == self.points.len() && self.light; - (self.varies_size() && self.size_pressure && !self.smoothing && pressed).then_some(self.pressures.as_slice()) + (self.tip_modes()[2] == Variation::Pressure && !self.smoothing && pressed).then_some(self.pressures.as_slice()) + } + + /// How the tip's angle, roundness and size vary: the Eraser's three modes, the Blob Brush's + /// Size mode (its angle and roundness stay fixed), and Fixed for the other tools. A mode whose + /// Variation is 0 is Fixed. + fn tip_modes(&self) -> [Variation; 3] { + let fixed_at_zero = |mode: Variation, v: f64| if v > 0.0 { mode } else { Variation::Fixed }; + match self.id { + "eraser" => [ + fixed_at_zero(self.angle_mode, self.angle_variation), + fixed_at_zero(self.roundness_mode, self.roundness_variation), + fixed_at_zero(self.size_mode, self.size_variation), + ], + "blobBrush" => [Variation::Fixed, Variation::Fixed, fixed_at_zero(self.size_mode, self.size_variation)], + _ => [Variation::Fixed; 3], + } + } + + /// The Eraser's angle, roundness and size where its tip outline shows: while it erases, each + /// value on a mode that follows the pen goes from the value less its Variation to the value + /// plus it as the pen reads now (the pressure once the pen has pressed less than fully, the + /// tilt and rotation of the last point, and the bearing of the last point the pen leaned 1° or + /// more at), as `path.eraseRegion` reads the stroke; before a press, and for Random, Fixed and a + /// pen that gives no reading, the values themselves. The Blob Brush's are its own values. + fn tip_now(&self) -> [f64; 3] { + let base = [self.angle, self.roundness, self.size]; + if self.id != "eraser" || !self.active { + return base; + } + let pressure = if self.light { self.pressures.last().map_or(0.5, |p| f64::from(*p)) } else { 0.5 }; + let controls = super::pen_controls(pressure, self.poses.last().copied().flatten(), self.bearing); + let variations = [self.angle_variation, self.roundness_variation, self.size_variation]; + let mut out = base; + for (i, (v, mode)) in out.iter_mut().zip(self.tip_modes()).enumerate() { + let c = match mode { + Variation::Pressure => controls[0], + Variation::Tilt => controls[1], + Variation::Bearing => controls[2], + Variation::Rotation => controls[3], + Variation::Fixed | Variation::Random => continue, + }; + // The size goes past a nib's 1296 pt, as `path.eraseRegion` sweeps it. + *v = if i == 2 { super::pressure_size(*v, variations[i], c) } else { Calligraphic::moved(i, *v, variations[i] * (2.0 * c - 1.0)) }; + } + let [angle, roundness, size] = out; + [angle, roundness.clamp(1.0, 100.0), size.max(super::MIN_BRUSH_SIZE)] } /// The stroke's points: `[x, y, pressure, tiltX, tiltY, rotation]` when a pen read its tilt or @@ -1254,7 +1347,7 @@ impl GestureTool { json!({ "points": self.stroke_json(), "size": self.size, - "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeMode": self.size_mode.id(), "sizeVariation": self.size_variation, "fidelity": self.fidelity, "angle": self.angle, @@ -1268,10 +1361,14 @@ impl GestureTool { json!({ "points": self.stroke_json(), "size": self.size, - "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeMode": self.size_mode.id(), "sizeVariation": self.size_variation, "angle": self.angle, - "roundness": self.roundness + "angleMode": self.angle_mode.id(), + "angleVariation": self.angle_variation, + "roundness": self.roundness, + "roundnessMode": self.roundness_mode.id(), + "roundnessVariation": self.roundness_variation }), )], "knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))], @@ -1385,6 +1482,9 @@ impl Tool for GestureTool { let release_end = kept.unwrap_or(end); self.near_start = self.release_closes_near_start(cx, release_end); self.connects = self.connects_at(cx, release_end).is_some(); + if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) { + self.refresh_last_pose(ev); + } return vec![]; } self.push(p, ev); @@ -1438,13 +1538,17 @@ impl Tool for GestureTool { } } else if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) { self.push(end, ev); - } else if self.grid_snaps(cx) - && !self.smoothing - && self.points.len() > 1 - && let Some(last) = self.points.last_mut() - { - // The last step was where the button went up: it moves where that snaps. - *last = end; + } else { + // A release within a pixel of the last step gives it the pen's pose there. + self.refresh_last_pose(ev); + if self.grid_snaps(cx) + && !self.smoothing + && self.points.len() > 1 + && let Some(last) = self.points.last_mut() + { + // The last step was where the button went up: it moves where that snaps. + *last = end; + } } let out = if cancel { vec![Action::Cancel] } else { self.finish(cx, ev.mods) }; self.active = false; @@ -1666,7 +1770,7 @@ impl Tool for GestureTool { json!({ "fidelity": self.fidelity, "size": self.size, - "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeMode": self.size_mode.id(), "sizeVariation": self.size_variation, "angle": self.angle, "roundness": self.roundness, @@ -1676,10 +1780,14 @@ impl Tool for GestureTool { } "eraser" => json!({ "size": self.size, - "sizeMode": if self.size_pressure { "pressure" } else { "fixed" }, + "sizeMode": self.size_mode.id(), "sizeVariation": self.size_variation, "angle": self.angle, - "roundness": self.roundness + "angleMode": self.angle_mode.id(), + "angleVariation": self.angle_variation, + "roundness": self.roundness, + "roundnessMode": self.roundness_mode.id(), + "roundnessVariation": self.roundness_variation }), _ => Value::Null, } @@ -1688,12 +1796,19 @@ impl Tool for GestureTool { match key { "fidelity" => self.fidelity = v.as_f64().unwrap_or(self.fidelity).clamp(super::min_fidelity(self.id), 100.0), "size" => self.size = v.as_f64().unwrap_or(self.size).clamp(0.1, 1000.0), - "sizeMode" if self.varies_size() => match v.as_str() { - Some("pressure") => self.size_pressure = true, - Some("fixed") => self.size_pressure = false, + // The Blob Brush's Size is Fixed or Pressure; the Eraser's Angle, Roundness and Size + // take any mode. + "sizeMode" if self.varies_size() => match v.as_str().and_then(Variation::parse) { + Some(m) if self.id == "eraser" || matches!(m, Variation::Fixed | Variation::Pressure) => self.size_mode = m, _ => {} }, + "angleMode" if self.id == "eraser" => self.angle_mode = v.as_str().and_then(Variation::parse).unwrap_or(self.angle_mode), + "roundnessMode" if self.id == "eraser" => self.roundness_mode = v.as_str().and_then(Variation::parse).unwrap_or(self.roundness_mode), "sizeVariation" if self.varies_size() => self.size_variation = v.as_f64().unwrap_or(self.size_variation).clamp(0.0, 1000.0), + "angleVariation" if self.id == "eraser" => self.angle_variation = v.as_f64().unwrap_or(self.angle_variation).clamp(0.0, 180.0), + "roundnessVariation" if self.id == "eraser" => { + self.roundness_variation = v.as_f64().unwrap_or(self.roundness_variation).clamp(0.0, 100.0) + } "angle" => self.angle = v.as_f64().unwrap_or(self.angle).clamp(-180.0, 180.0), "roundness" => self.roundness = v.as_f64().unwrap_or(self.roundness).clamp(if self.id == "blobBrush" { 0.0 } else { 1.0 }, 100.0), "fill" => self.fill = v.as_bool().unwrap_or(self.fill), diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 4db9310c79..9dfbdba2de 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -527,10 +527,20 @@ fn blob_brush_sends_keep_selected_and_merge_only_with_selection() { "{acts:?}" ); } - // The Eraser keeps Size (Fixed by default), Angle and Roundness: a round tip by default. + // The Eraser keeps Size, Angle and Roundness, each Fixed by default: a round tip by default. assert_eq!( GestureTool::new("eraser").options(), - json!({"size": 10.0, "sizeMode": "fixed", "sizeVariation": 0.0, "angle": 0.0, "roundness": 100.0}) + json!({ + "size": 10.0, + "sizeMode": "fixed", + "sizeVariation": 0.0, + "angle": 0.0, + "angleMode": "fixed", + "angleVariation": 0.0, + "roundness": 100.0, + "roundnessMode": "fixed", + "roundnessVariation": 0.0 + }) ); } @@ -2768,6 +2778,161 @@ fn the_blob_brush_and_the_eraser_show_their_tip_at_the_pointer() { } } +/// Eraser Tool Options' Angle, Roundness and Size variation menus: the Eraser keeps each mode and +/// Variation, the release sends them with the pen's readings along the drag, and while it erases +/// its tip outline at the pointer takes the angle, roundness and size the pen gives there. The Blob +/// Brush's Size takes Fixed and Pressure only, and it has no Angle or Roundness menus. +#[test] +fn the_eraser_tip_follows_its_variation_modes() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + // The bounds of the tip outline, the last two overlays (its halo and its line). + let tip = |t: &GestureTool| match &t.overlays(&cx)[..] { + [.., Overlay::Path { path: halo, width: 3.0, .. }, Overlay::Path { path, color: [0, 0, 0], width: 1.0, .. }] => { + assert_eq!(halo, path); + vectorcraft_geom::Shape::bounding_box(path) + } + o => panic!("{o:?}"), + }; + let near = |b: vectorcraft_geom::Rect, want: (f64, f64, f64, f64)| { + assert!([b.x0 - want.0, b.y0 - want.1, b.x1 - want.2, b.y1 - want.3].iter().all(|e| e.abs() < 0.01), "{b:?} vs {want:?}"); + }; + let mut t = GestureTool::new("eraser"); + assert_eq!( + (t.options()["angleMode"].clone(), t.options()["roundnessMode"].clone(), t.options()["sizeMode"].clone()), + (json!("fixed"), json!("fixed"), json!("fixed")), + "Fixed by default" + ); + for (k, v) in [ + ("size", json!(20)), + ("roundness", json!(25)), + ("angleMode", json!("rotation")), + ("angleVariation", json!(90)), + ("roundnessMode", json!("random")), + ("roundnessVariation", json!(10)), + ("sizeMode", json!("tilt")), + ("sizeVariation", json!(8)), + ] { + t.set_option(k, &v); + } + let o = t.options(); + assert_eq!( + [&o["angleMode"], &o["angleVariation"], &o["roundnessMode"], &o["roundnessVariation"], &o["sizeMode"], &o["sizeVariation"]], + [&json!("rotation"), &json!(90.0), &json!("random"), &json!(10.0), &json!("tilt"), &json!(8.0)] + ); + // A mode it does not have leaves the mode as it was; a Variation is kept within its range. + t.set_option("angleMode", &json!("wheel")); + t.set_option("roundnessVariation", &json!(500)); + assert_eq!((t.options()["angleMode"].clone(), t.options()["roundnessVariation"].clone()), (json!("rotation"), json!(100.0))); + t.set_option("roundnessVariation", &json!(10)); + // Before a press the outline is the set tip: 20 by 5 pt, lying level. + t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 100.0, 50.0)); + near(tip(&t), (90.0, 47.5, 110.0, 52.5)); + // Erasing with the barrel turned half way round and the pen upright, the tip stands upright and + // is 28 by 7 pt; Random leaves the roundness as set. + let pen = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: Some(180.0) }); + t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (196.5, 36.0, 203.5, 64.0)); + let acts = t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + let Some(Action::Exec(c, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(c, "path.eraseRegion"); + assert_eq!( + [&v["angleMode"], &v["angleVariation"], &v["roundnessMode"], &v["roundnessVariation"], &v["sizeMode"], &v["sizeVariation"]], + [&json!("rotation"), &json!(90.0), &json!("random"), &json!(10.0), &json!("tilt"), &json!(8.0)] + ); + assert_eq!( + v["points"], + json!([[100.0, 50.0, 0.5, 0.0, 0.0, 180.0], [200.0, 50.0, 0.5, 0.0, 0.0, 180.0]]), + "the pen's readings go with the points" + ); + // A mouse drag, which reads no tilt or rotation, keeps the set tip. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 50.0)); + near(tip(&t), (190.0, 47.5, 210.0, 52.5)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 50.0)); + // A point without a reading after one with it goes back to the set tip there, as the release + // erases it. + t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (190.0, 47.5, 210.0, 52.5)); + t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + // The Blob Brush's Size is Fixed or Pressure, and its Angle and Roundness have no menus. + let mut b = GestureTool::new("blobBrush"); + b.set_option("sizeMode", &json!("tilt")); + b.set_option("angleMode", &json!("rotation")); + assert_eq!(b.options()["sizeMode"], json!("fixed")); + assert!(b.options().get("angleMode").is_none() && b.options().get("roundnessVariation").is_none()); + b.set_option("sizeMode", &json!("pressure")); + assert_eq!(b.options()["sizeMode"], json!("pressure")); +} + +/// The Eraser's tip outline follows a pen turned in place, which adds no stroke point, and the +/// release erases with that reading; and its size passes 1296 pt as `path.eraseRegion` sweeps it. +#[test] +fn the_eraser_tip_turns_in_place_and_passes_a_nib_size() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let tip = |t: &GestureTool| match &t.overlays(&cx)[..] { + [.., Overlay::Path { path, color: [0, 0, 0], width: 1.0, .. }] => vectorcraft_geom::Shape::bounding_box(path), + o => panic!("{o:?}"), + }; + let near = |b: vectorcraft_geom::Rect, want: (f64, f64, f64, f64)| { + assert!([b.x0 - want.0, b.y0 - want.1, b.x1 - want.2, b.y1 - want.3].iter().all(|e| e.abs() < 0.01), "{b:?} vs {want:?}"); + }; + let mut t = GestureTool::new("eraser"); + for (k, v) in [("size", json!(20)), ("roundness", json!(25)), ("angleMode", json!("rotation")), ("angleVariation", json!(90))] { + t.set_option(k, &v); + } + let level = Some(PenPose { tilt: None, rotation: Some(0.0) }); + let turned = Some(PenPose { tilt: None, rotation: Some(180.0) }); + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (190.0, 47.5, 210.0, 52.5)); + // The barrel turns half way round with the pointer still: the tip stands upright. + t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (197.5, 40.0, 202.5, 60.0)); + let acts = t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]])); + // A release at the last point erases with the pose the pen has as it lifts. + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + let acts = t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]])); + // A release there that reads no pose keeps the last one. + t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + let acts = t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]])); + // With Angle on Bearing, a pen that leaned only at the last point and stands up there again + // leaves no bearing: the tip lies level, as set. + t.set_option("angleMode", &json!("bearing")); + let upright = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: None }); + let leaning = Some(PenPose { tilt: Some([0.0, 45.0]), rotation: None }); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: leaning, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + assert!(tip(&t).height() > 6.0, "{:?}", tip(&t)); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (190.0, 47.5, 210.0, 52.5)); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) }); + // Size 1000 with a Variation of 1000 on Tilt, the pen upright: a tip 2000 pt across. + let mut t = GestureTool::new("eraser"); + for (k, v) in [("size", json!(1000)), ("sizeMode", json!("tilt")), ("sizeVariation", json!(1000))] { + t.set_option(k, &v); + } + let upright = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: None }); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) }); + t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) }); + near(tip(&t), (-800.0, -950.0, 1200.0, 1050.0)); +} + #[test] fn smooth_needs_selection_and_brackets_resize() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/pathtype.rs b/crates/tools/src/pathtype.rs index 9f888de6cb..e54e23b7a7 100644 --- a/crates/tools/src/pathtype.rs +++ b/crates/tools/src/pathtype.rs @@ -59,6 +59,8 @@ pub struct Brackets { arc: ArcPath, /// [`TextKind::path_span`]. span: (f64, f64), + /// [`TextKind::path_text_start`]: below 0 when the type starts before an open path's start. + text_start: f64, /// The end bracket was placed (not the end of the path, or once round it). end_set: bool, } @@ -78,7 +80,9 @@ impl Brackets { if !det.is_finite() || det.abs() < 1e-12 || arc.is_empty() { return None; } - Some(Self { id, xf: t.xf, arc, span: t.kind.path_span()?, end_set: end.is_some() }) + let span = t.kind.path_span()?; + let text_start = t.kind.path_text_start().unwrap_or(span.0); + Some(Self { id, xf: t.xf, arc, span, text_start, end_set: end.is_some() }) } /// Where bracket `b` stands, as a fraction of the path's length (past 1 round a closed path). @@ -269,7 +273,13 @@ impl BracketDrag { d -= d.round(); } let place = |x: f64| if closed { x.rem_euclid(1.0) } else { x.clamp(0.0, 1.0) }; - let start = place(s + d); + // Type that starts before an open path's start slides from there, and goes no + // further before the path than it was: both brackets stay where they are. + let lead = !closed && b.text_start < 0.0; + if lead { + d = d.max(0.0); + } + let start = if lead { (b.text_start + d).min(1.0) } else { place(s + d) }; m.insert("start".into(), json!(start)); if b.end_set { m.insert("end".into(), json!(if closed { place(e + d) } else { place(e + d).max(start) })); @@ -409,6 +419,35 @@ mod tests { assert!(v.get("flip").is_none(), "{v}"); } + /// Type that starts before its open path slides from where it starts, not from the path's start. + #[test] + fn dragging_the_center_bracket_slides_type_that_starts_before_the_path() { + let (mut d, id) = doc_with_path_type(); + if let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) + && let TextKind::OnPath { start, .. } = &mut t.kind + { + *start = -0.25; + } + let s = selected(id); + let p = paint(); + let cx = cx(&d, &s, &p); + // The center bracket stands in the middle of the brackets (the start bracket at the path's start). + let v = preview(&drag(&cx, Point::new(250.0, 295.0), Point::new(280.0, 295.0), Mods::default())).clone(); + assert!((v["start"].as_f64().unwrap() + 0.15).abs() < 1e-6, "{v}"); + // Back past where it started: it stays there, and so does a placed end bracket. + let v = preview(&drag(&cx, Point::new(250.0, 295.0), Point::new(150.0, 295.0), Mods::default())).clone(); + assert!((v["start"].as_f64().unwrap() + 0.25).abs() < 1e-6, "{v}"); + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) + && let TextKind::OnPath { end, .. } = &mut t.kind + { + *end = Some(0.5); + } + let ended = crate::testutil::cx(&d, &s, &p); + // The center bracket stands at 0.25 of the path, between the start bracket (0) and the end. + let v = preview(&drag(&ended, Point::new(175.0, 295.0), Point::new(130.0, 295.0), Mods::default())).clone(); + assert!((v["start"].as_f64().unwrap() + 0.25).abs() < 1e-6 && (v["end"].as_f64().unwrap() - 0.5).abs() < 1e-6, "{v}"); + } + #[test] fn dragging_the_start_and_end_brackets_moves_them() { let (d, id) = doc_with_path_type(); diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index a4d1b1a64f..a5c92b00b0 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -43,6 +43,8 @@ const ROWS: &[Row] = &[ (&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection"]), // Eraser Tool Options' tip shape. (&["blobBrush", "eraser"], None, &["angle", "roundness"]), + // Eraser Tool Options' Angle and Roundness variation menus and fields. + (&["eraser"], None, &["angleMode", "angleVariation", "roundnessMode", "roundnessVariation"]), // Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`). (&["livePaintSelection"], None, &["selectFills", "selectStrokes", "color", "width"]), // Live Paint Bucket Options (`builder::LivePaintBucketTool`). diff --git a/crates/tools/src/touchtype.rs b/crates/tools/src/touchtype.rs index d195f276e4..ba8d0b1488 100644 --- a/crates/tools/src/touchtype.rs +++ b/crates/tools/src/touchtype.rs @@ -150,7 +150,7 @@ fn is_inline(lay: &TextLayout, gi: usize) -> bool { /// inline graphics, glyphs whose font is gone and type whose transform is collapsed. fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option { let g: &PositionedGlyph = lay.glyphs.get(gi)?; - if is_inline(lay, gi) { + if is_inline(lay, gi) || g.off_path { return None; } let m = g.xf; @@ -161,7 +161,7 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option { let corners: Vec = lay .glyphs .iter() - .filter(|h| h.byte == g.byte && h.len > 0 && h.line == g.line) + .filter(|h| h.byte == g.byte && h.len > 0 && h.line == g.line && !h.off_path) .filter_map(glyph_quad) .flatten() .map(|p| to_glyph * p) @@ -218,7 +218,8 @@ impl TouchTypeTool { let selected = std::iter::successors(Some(id), |c| cx.doc.parent_of(*c)).any(|c| cx.selection.contains(c)); let t = text_of(cx, id).filter(|_| selected)?; let lay = self.layout(t); - let gi = lay.glyphs.iter().position(|g| g.byte <= start && start < g.byte + g.len)?; + // The character's first drawn glyph: glyphs before an open path's start are not drawn. + let gi = lay.glyphs.iter().position(|g| g.byte <= start && start < g.byte + g.len && !g.off_path)?; Some((id, t, char_box(t, &lay, gi)?)) } @@ -230,7 +231,7 @@ impl TouchTypeTool { let lay = self.layout(t); let local = t.xf.inverse() * p; let boxes: Vec<(usize, [Point; 4])> = (lay.glyphs.iter().enumerate()) - .filter(|(gi, g)| g.len > 0 && !is_inline(&lay, *gi)) + .filter(|(gi, g)| g.len > 0 && !g.off_path && !is_inline(&lay, *gi)) .filter_map(|(_, g)| Some((g.byte, glyph_quad(g)?))) .collect(); let center = |q: &[Point; 4]| q[0].midpoint(q[2]); @@ -360,7 +361,9 @@ impl Tool for TouchTypeTool { let Some(t) = text_of(cx, id) else { return out }; let lay = self.layout(t); let start = self.target.map_or(0, |(_, s)| s); - let Some(b) = lay.glyphs.iter().position(|g| g.byte == start && g.len > 0).and_then(|gi| char_box(t, &lay, gi)) else { + // The character's first drawn glyph: glyphs before an open path's start are not drawn. + let first = lay.glyphs.iter().position(|g| g.byte == start && g.len > 0 && !g.off_path); + let Some(b) = first.and_then(|gi| char_box(t, &lay, gi)) else { return out; }; (Part::Move, b) diff --git a/crates/tools/src/touchtype/tests.rs b/crates/tools/src/touchtype/tests.rs index 3b551950d1..54c6c86e30 100644 --- a/crates/tools/src/touchtype/tests.rs +++ b/crates/tools/src/touchtype/tests.rs @@ -388,6 +388,60 @@ fn the_box_of_a_character_drawn_with_two_glyphs_holds_both() { } } +/// A character of type on an open path that is not drawn, because it falls before the path's +/// start, has no box, so the tool shows no handles for it; a drawn character of the same type has +/// one. +#[test] +fn a_character_before_an_open_paths_start_has_no_box() { + let mut d = Document::new(600.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut text = TextObject::point(Point::ZERO, "Type", CharStyle { size: 40.0, ..Default::default() }); + let line = + vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(500.0, 200.0)], false)]); + text.kind = vectorcraft_doc::TextKind::OnPath { path: line, start: -0.05, end: None }; + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let lay = vectorcraft_text::layout(FontDb::global(), &text); + assert!(lay.glyphs[0].off_path && !lay.glyphs[1].off_path, "the T is before the path's start, the y is on it"); + assert!(char_box(&text, &lay, 0).is_none()); + assert!(TouchTypeTool { target: Some((id, 0)), ..Default::default() }.current(&cx).is_none()); + assert!(TouchTypeTool { target: Some((id, 1)), ..Default::default() }.current(&cx).is_some()); + // An All Caps ß is two glyphs: with the first before the path's start and the second drawn, the + // character has the second's box. + let mut caps = text.clone(); + caps.runs = vec![vectorcraft_doc::TextRun::new("ße", CharStyle { size: 40.0, all_caps: true, ..Default::default() })]; + if let vectorcraft_doc::TextKind::OnPath { start, .. } = &mut caps.kind { + *start = 0.0; + } + let adv = vectorcraft_text::layout(FontDb::global(), &caps).glyphs[0].advance; + if let vectorcraft_doc::TextKind::OnPath { start, .. } = &mut caps.kind { + *start = -1.2 * adv / 400.0; + } + let mut d = Document::new(600.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(caps.clone())))).unwrap(); + let mut s = Selection::default(); + s.add(id); + let cx = crate::testutil::cx(&d, &s, &p); + let lay = vectorcraft_text::layout(FontDb::global(), &caps); + assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path); + let t = TouchTypeTool { target: Some((id, 0)), ..Default::default() }; + let (_, _, b) = t.current(&cx).expect("the ß has a box"); + assert!((b.pen - lay.glyphs[1].xf * Point::ZERO).hypot() < 1e-9, "{:?}", b.pen); + // A press on its drawn glyph, on the path, starts a drag of it. + let q = glyph_quad(&lay.glyphs[1]).unwrap(); + let at = Point::new(q[0].midpoint(q[2]).x, 200.0); + assert!(in_quad(&q, at), "{at:?} {q:?}"); + let mut t = TouchTypeTool::default(); + let acts = drag(&mut t, &cx, at, at + Vec2::new(0.0, -5.0)); + assert_eq!(preview(&acts[1])["start"], 0, "{:?}", acts[1]); +} + /// A glyph skewed by its transform (the Skew effect of type on a path) keeps its own box. #[test] fn a_skewed_glyph_keeps_its_own_box() { diff --git a/crates/ui-egui/src/addons/pathtools/corner.rs b/crates/ui-egui/src/addons/pathtools/corner.rs index ace9be954c..58cc95882d 100644 --- a/crates/ui-egui/src/addons/pathtools/corner.rs +++ b/crates/ui-egui/src/addons/pathtools/corner.rs @@ -19,7 +19,7 @@ const FIELD_W: f32 = 96.0; /// Object › Plug-ins › Path Precision Tools › Corner Tool. pub fn open(app: &mut VectorcraftApp) -> Result { - app.select_tool(ID); + app.try_select_tool(ID)?; crate::panel_docking::reveal_panel(app, super::ID) } diff --git a/crates/ui-egui/src/addons/pathtools/shared_edge.rs b/crates/ui-egui/src/addons/pathtools/shared_edge.rs index d682aad776..57254e1aec 100644 --- a/crates/ui-egui/src/addons/pathtools/shared_edge.rs +++ b/crates/ui-egui/src/addons/pathtools/shared_edge.rs @@ -17,7 +17,7 @@ const LABEL_W: f32 = 80.0; /// Object › Plug-ins › Path Precision Tools › Shared Edge Merge Tool. pub fn open(app: &mut VectorcraftApp) -> Result { - app.select_tool(ID); + app.try_select_tool(ID)?; crate::panel_docking::reveal_panel(app, super::ID) } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 658068f79b..f708d0f454 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -185,6 +185,8 @@ impl VectorcraftApp { host_iteration: 0, #[cfg(test)] now_override: None, + #[cfg(test)] + fail_next_tool_choice: None, tick_iteration: None, input_frames: vec![], shortcuts_frames: vec![], diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 6d40499106..8cf742e499 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -406,34 +406,54 @@ impl VectorcraftApp { } } - /// Select a tool (also used by the toolbar and shortcuts). + /// Select a tool (also used by the toolbar and shortcuts). A choice that fails shows its error + /// in the status bar. pub fn select_tool(&mut self, id: &str) { + if let Err(e) = self.try_select_tool(id) { + self.ui.status = e; + } + } + + /// Select a tool for a command ([`Self::select_tool`]): a choice that fails returns its error, + /// and the tool remembered for the toolbar group and for the Touch workspace's drawing mode + /// stays as it was. + pub(crate) fn try_select_tool(&mut self, id: &str) -> Result<(), String> { // The tip of a pen whose eraser end selected the Eraser doesn't undo this choice, and // neither does letting go of a held tool shortcut. self.pen_end.forget_tool(); self.held_tool.clear(); - self.activate_tool(id, true); + self.activate_tool(id, true)?; let now = self.session.tool_id(); if crate::touch::is_drawing_tool(now) { self.ui.touch_drawing_tool = Some(now.to_string()); } + Ok(()) } /// Go back to tool `id` after a tool that stood in for it (the pen's eraser end, [`stylus`]): - /// a perspective grid hidden meanwhile stays hidden. + /// a perspective grid hidden meanwhile stays hidden. A switch that fails shows its error in the + /// status bar. pub(crate) fn return_to_tool(&mut self, id: &str) { - self.activate_tool(id, false); + if let Err(e) = self.activate_tool(id, false) { + self.ui.status = e; + } } /// Make tool `id` active: chosen afresh (`fresh`, [`vectorcraft_engine::Session::select_tool`]), /// or back from a tool that stood in for it ([`vectorcraft_engine::Session::switch_tool`]). - fn activate_tool(&mut self, id: &str, fresh: bool) { + /// The toolbar group remembers `id` only when the switch succeeds. + fn activate_tool(&mut self, id: &str, fresh: bool) -> Result<(), String> { let v = self.view_info(); let before = self.session.tool_id(); - let switched = if fresh { self.session.select_tool(id, v) } else { self.session.switch_tool(id, v) }; - if let Err(e) = switched { - self.ui.status = e.to_string(); - } + #[cfg(test)] + let failed = self.fail_next_tool_choice.take(); + #[cfg(not(test))] + let failed: Option = None; + let switched = match failed { + Some(e) => Err(e), + None if fresh => self.session.select_tool(id, v).map_err(|e| e.to_string()), + None => self.session.switch_tool(id, v).map_err(|e| e.to_string()), + }; if before != self.session.tool_id() { // A control gesture cannot resume after another tool has owned the pointer. self.control_rotate_press = None; @@ -445,13 +465,15 @@ impl VectorcraftApp { { panels::artboards::select(self, panels::artboards::selected(self, n)); } + self.ui.flyout = None; + switched?; if let Some(g) = vectorcraft_tools::catalog::group_of(id) && let Some(slot) = self.ui.group_tool.get_mut(g) { *slot = id.to_string(); } toolbar::remember(self, id); - self.ui.flyout = None; + Ok(()) } /// Show a transient status message. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7095e6a9ed..847b60fd87 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -66,7 +66,7 @@ use overlays::selected_anchor; use overlays::{HandleLook, blend_widgets_overlay, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay}; pub use panes::Panes; pub(crate) use panes::{consolidate, pane, tile, tiled_area}; -use raster::{changed_pixels, pixel_region, upload}; +use raster::{changed_pixels, frame_ppp, pixel_region, upload}; pub(crate) use raster::{color_image, color_image_owned, render_job}; pub use selection_marks::SelectionMarks; #[cfg(test)] @@ -326,7 +326,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } // Artwork raster. - let ppp = ui.ctx().pixels_per_point(); + let device_ppp = ui.ctx().pixels_per_point(); + let max_side = ui.ctx().input(|i| i.max_texture_side); + let ppp = frame_ppp(rect.size(), device_ppp, max_side); // Match Renderer::render_as before recording the cache extent or building its view. let (w, h) = ( ((rect.width() * ppp).round().max(1.0) as u32).min(u32::from(u16::MAX)), @@ -334,7 +336,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ); // View › Pixel Preview: the art as it rasterizes, one pixel per point, while a document pixel // is bigger than a screen pixel (below that the screen render already shows it). - let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); + let pixel = (app.ui.view.pixel_preview && xf.zoom * device_ppp as f64 > 1.0).then(|| pixel_region(&xf, max_side)).flatten(); // File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview: off, images show // their pixels as they rasterize, hard-edged. let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 88bb262844..69849ebe11 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -5,8 +5,8 @@ use super::drag::{ tool_drag_moved, tool_frame_moves, tool_holds, tool_press_doc_id, }; use super::view::{ - HOLD_DELAY, HOLD_ZOOM, SCRUB_SLOP, SCRUB_ZOOM, WheelTurn, animated_zoom, pan_pixels, rotate_about, scroll_view, touch_gestures, trackpad_turn, - wheel, whole_pixels, zoom_about, + HOLD_DELAY, HOLD_ZOOM, SCRUB_SLOP, SCRUB_ZOOM, WheelTurn, animated_zoom, pan_pixels, pan_ppp, rotate_about, scroll_view, touch_gestures, + trackpad_turn, wheel, whole_pixels, zoom_about, }; use super::{Color32, Drag, Mods, Point, PointerEvent, PointerKind, Pos2, Shape, Stroke, Ui, VectorcraftApp, View, Xf, json}; @@ -367,7 +367,7 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) let ui_zoom = app.canvas_ui_zoom; match d { Drag::Pan { start, center, .. } => { - let ppp = ui.ctx().pixels_per_point(); + let ppp = pan_ppp(app, ui); if let Some(vm) = app.view_mut() { vm.center = center; pan_pixels(vm, (ppp, ui_zoom), whole_pixels(p - start, ppp)); diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index 50fd2c9583..bf070af076 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -12,7 +12,7 @@ use super::outline::{c32, node_outline, stroke_path, too_many_anchors}; use super::view::fit_zoom; use super::{ Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, backdrop, cache_key, - dim_on_canvas, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, placed_key, plugin_key, pos2, rulers, + dim_on_canvas, frame_options, frame_ppp, frame_view, grid, guides_of, json, paint_paper, pixel_region, placed_key, plugin_key, pos2, rulers, shows_artboards, textured_quad, vec2, view_rects, }; use crate::doc_windows::Win; @@ -339,10 +339,13 @@ fn draw_pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) -> boo if let Some(look) = grid_look.filter(|_| grids_in_back) { grid(&painter, &xf, st.doc.grid.spacing, st.doc.grid.subdivisions, look); } + // View › Pixel Preview, as the canvas draws it. + let max_side = ui.ctx().input(|i| i.max_texture_side); + let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf, max_side)).flatten(); + // The pane's frame fits the largest texture side the display supports, as the canvas's does. + let ppp = frame_ppp(rect.size(), ppp, max_side); let size = |l: f32| ((l * ppp).round().max(1.0) as u32).min(u32::from(u16::MAX)); let (pw, ph) = (size(rect.width()), size(rect.height())); - // View › Pixel Preview, as the canvas draws it. - let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st); let placed = placed_key(&mut app.canvas.placed_keys, &app.session, st); diff --git a/crates/ui-egui/src/canvas/panes/tests.rs b/crates/ui-egui/src/canvas/panes/tests.rs index 005b930d01..3a45d90157 100644 --- a/crates/ui-egui/src/canvas/panes/tests.rs +++ b/crates/ui-egui/src/canvas/panes/tests.rs @@ -323,6 +323,40 @@ fn other_panes_show_pixel_preview() { assert!(x1 > x0 && y1 > y0); } +/// A pane more device pixels across than the largest texture side the display supports renders +/// its document within that size, as the canvas does, and the frame covers the pane. +#[test] +fn other_panes_render_within_the_largest_texture_side() { + let mut app = app_with(2); + app.canvas.panes.worker_started = true; // every pane renders on this thread + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + app.run("window.arrange.tile", json!({})).unwrap(); + // The first frame sets the pixels a point, the second draws at them. + for _ in 0..2 { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))), + max_texture_side: Some(1024), + ..Default::default() + }; + ctx.run_ui(raw, |ui| app.canvas_area(ui)).textures_delta.clear(); + } + let uid = (app.session.documents()[0].uid, 1); + let (key, tex) = app.canvas.panes.shown.get(&uid).map(|(k, t)| (k.clone(), t.size())).expect("the other document's pane rendered"); + assert_eq!(tex[0].max(tex[1]), 1024, "the pane's frame is {tex:?} pixels"); + let (docs, cells) = panes(&app); + let pane = docs.iter().zip(cells).find(|(i, _)| **i == 0).map(|(_, r)| r).unwrap(); + let rect = view_rects(&app, pane, ctx.pixels_per_point()).1; + assert!(rect.width().max(rect.height()) * ctx.pixels_per_point() > 1024.0, "the pane is {rect:?} at {} pixels a point", ctx.pixels_per_point()); + assert!( + (key.w as f32 / key.ppp - rect.width()).abs() < 1.0 && (key.h as f32 / key.ppp - rect.height()).abs() < 1.0, + "the frame covers the pane {rect:?}: {} × {} pixels at {} a point", + key.w, + key.h, + key.ppp + ); +} + /// A pane's render key takes its document's [`CacheKey::placed`] value ([`placed_key`]), which a /// bitmap made for a test running beside this one does not move: the document holds no images. #[test] diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs index 2f9a4f981f..8f167bdfa2 100644 --- a/crates/ui-egui/src/canvas/raster.rs +++ b/crates/ui-egui/src/canvas/raster.rs @@ -242,9 +242,20 @@ pub(super) fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: vectorc } } +/// The device pixels a point that the art's frame of a canvas `size` points across renders at: +/// the display's `ppp`, lowered when a side of the frame would be more than `max_side` pixels, the +/// largest texture side the display supports (egui's `max_texture_side`). The frame is then drawn +/// scaled up over the canvas, at a lower resolution. +pub(super) fn frame_ppp(size: egui::Vec2, ppp: f32, max_side: usize) -> f32 { + let longest = size.x.max(size.y) * ppp; + let max = max_side.clamp(1, usize::from(u16::MAX)) as f32; + if longest.round() <= max { ppp } else { ppp * (max / longest) } +} + /// Pixel Preview: the whole document pixels (x0, y0, x1, y1) under the canvas, rotated view -/// included. None when the view is degenerate. -pub(super) fn pixel_region(xf: &Xf) -> Option<[i64; 4]> { +/// included. None when the view is degenerate, or when a side of the region is more than +/// `max_side` pixels, the largest texture side the display supports. +pub(super) fn pixel_region(xf: &Xf, max_side: usize) -> Option<[i64; 4]> { let r = xf.rect; let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|p| xf.to_doc(p)); let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); @@ -253,6 +264,7 @@ pub(super) fn pixel_region(xf: &Xf) -> Option<[i64; 4]> { } let (x0, y0, x1, y1) = (x0.floor(), y0.floor(), x1.ceil(), y1.ceil()); // A document pixel is bigger than a screen pixel here, so the region is about canvas-sized. - let ok = [x0, y0, x1, y1].iter().all(|c| c.is_finite() && c.abs() < 1e9) && x1 > x0 && y1 > y0 && x1 - x0 <= 16384.0 && y1 - y0 <= 16384.0; + let max = max_side.min(16384) as f64; + let ok = [x0, y0, x1, y1].iter().all(|c| c.is_finite() && c.abs() < 1e9) && x1 > x0 && y1 > y0 && x1 - x0 <= max && y1 - y0 <= max; ok.then_some([x0 as i64, y0 as i64, x1 as i64, y1 as i64]) } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 3e21381cb1..35acda02b7 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1415,6 +1415,64 @@ fn switch_selection_and_drawing_modes_command() { } } +/// A Switch Selection and Drawing Modes whose tool choice fails returns the error, in both +/// directions: the tool stays, the drawing tool to return to stays, and no undo step is added. +#[test] +fn a_failed_mode_switch_returns_its_error() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap(); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let steps = undo(&app); + app.select_tool("rectangle"); + app.ui.touch_drawing_tool = Some("pen".into()); + app.fail_next_tool_choice = Some("no switch".into()); + let r = app.run(crate::touch::TOGGLE_MODE, json!({})); + assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{r:?}"); + assert_eq!((app.session.tool_id(), app.ui.touch_drawing_tool.as_deref(), undo(&app)), ("rectangle", Some("pen"), steps), "drawing mode"); + app.select_tool("selection"); + app.fail_next_tool_choice = Some("no switch".into()); + let r = app.run(crate::touch::TOGGLE_MODE, json!({})); + assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{r:?}"); + assert_eq!((app.session.tool_id(), app.ui.touch_drawing_tool.as_deref(), undo(&app)), ("selection", Some("pen"), steps), "selection mode"); + // The next switch works again. + assert_eq!(app.run(crate::touch::TOGGLE_MODE, json!({})).unwrap(), json!({"tool": "pen"})); + assert_eq!(undo(&app), steps); +} + +/// The commands that choose a tool (Select Tool, Crop Image, and the Path Precision Tools' Corner +/// Tool and Shared Edge Merge Tool) return the error when the choice fails, and the tool and the +/// toolbar group's tool stay as they were. +#[test] +fn tool_commands_return_a_failed_choice() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let img = image::RgbaImage::from_pixel(20, 10, image::Rgba([200, 120, 40, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "texture.png", "dataBase64": data, "link": false, "at": [150, 150]})).unwrap(); + app.select_tool("selection"); + let group = vectorcraft_tools::catalog::group_of("ellipse").unwrap(); + let commands = [ + ("tool.select", json!({"tool": "ellipse"})), + ("ui.cropImage", json!({})), + (crate::addons::pathtools::corner::COMMAND, json!({})), + (crate::addons::pathtools::shared_edge::COMMAND, json!({})), + ]; + for (command, params) in commands { + let slot = app.ui.group_tool[group].clone(); + app.fail_next_tool_choice = Some("no switch".into()); + let r = app.run(command, params.clone()); + assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{command}: {r:?}"); + assert_eq!(app.session.tool_id(), "selection", "{command}"); + assert_eq!(app.ui.group_tool[group], slot, "{command}"); + app.run(command, params).unwrap(); + assert_ne!(app.session.tool_id(), "selection", "{command} works again"); + app.select_tool("selection"); + } +} + /// The Artboard tool's label holds the artboard's name (never translated); the tools' own /// labels are interface text. #[test] @@ -2492,6 +2550,45 @@ fn an_edit_renders_only_its_part_of_the_canvas() { assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "a new view renders all of it"); } +/// A canvas more device pixels across than the largest texture side the display supports renders +/// its art at fewer pixels a point, within that size, and draws it over the whole canvas. Pixel +/// Preview's region of document pixels stays within it too. +#[test] +fn the_art_renders_within_the_largest_texture_side() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.canvas.worker_started = true; // every frame on this thread, however long it takes + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + // The first frame sets the pixels a point, the second draws at them. + for _ in 0..2 { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + max_texture_side: Some(1024), + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui)); + out.textures_delta.clear(); + } + let rect = app.canvas_rect.unwrap(); + assert!(rect.width() * ctx.pixels_per_point() > 1024.0, "the canvas is {rect:?} at {} pixels a point", ctx.pixels_per_point()); + let size = app.canvas.texture.as_ref().map(|t| t.size()).unwrap(); + assert_eq!(size[0].max(size[1]), 1024, "the frame is {size:?} pixels"); + let k = app.canvas.key.clone().unwrap(); + assert!( + (k.w as f32 / k.ppp - rect.width()).abs() < 1.0 && (k.h as f32 / k.ppp - rect.height()).abs() < 1.0, + "the frame covers the canvas {rect:?}: {} × {} pixels at {} a point", + k.w, + k.h, + k.ppp + ); + + let xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 500.0)), zoom: 0.5, center: Point::new(0.0, 0.0), rot: 0.0 }; + let [x0, _, x1, _] = pixel_region(&xf, 4096).expect("a region 2,000 document pixels wide"); + assert_eq!(x1 - x0, 2000); + assert_eq!(pixel_region(&xf, 1024), None, "more than the display's largest texture"); +} + /// A placed document's bitmap made in the background renders the canvas again only where the /// objects showing its file are: both copies of the file are in the part rendered again. #[test] @@ -3367,7 +3464,8 @@ fn a_drag_back_to_its_start_renders_again_after_it() { assert!(!app.canvas.moved); } -/// Cache metadata, worker requests and view transforms use the renderer's bounded raster size. +/// Cache metadata, worker requests and view transforms use the renderer's bounded raster size, +/// on a display that reports a larger texture side than that. /// Keep the worker controlled so huge virtual viewports never allocate huge test images. #[test] fn canvas_render_jobs_use_renderer_limited_dimensions() { @@ -3378,7 +3476,11 @@ fn canvas_render_jobs_use_renderer_limited_dimensions() { app.canvas.worker_started = true; app.ui.view.rulers = false; let frame = |app: &mut VectorcraftApp, size, ppp| { - let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(0.0, 0.0), size)), ..Default::default() }; + let mut input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(pos2(0.0, 0.0), size)), + max_texture_side: Some(1 << 20), + ..Default::default() + }; input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(ppp); let mut output = ctx.run_ui(input, |ui| show(app, ui)); output.textures_delta.clear(); @@ -4959,6 +5061,67 @@ fn scrolls_and_pans_move_the_view_by_whole_device_pixels() { frame(&mut app, vec![primary(p0 + vec2(-5.55, 22.71), false)]); } +/// On a canvas whose frame renders at fewer pixels a point than the display's ([`frame_ppp`]), +/// the wheel and the Hand tool move the view by whole pixels of that frame. +#[test] +fn scrolls_and_pans_move_a_lowered_frame_by_whole_pixels() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // every frame on this thread + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.016; + let input = egui::RawInput { time: Some(time), screen_rect: Some(screen), max_texture_side: Some(1024), events, ..Default::default() }; + ctx.run_ui(input, |ui| show(app, ui)).textures_delta.clear(); + }; + frame(&mut app, vec![]); + app.view_mut().unwrap().center = Point::new(200.3, 150.7); + frame(&mut app, vec![]); + let ppp = app.canvas.key.as_ref().map(|k| k.ppp).unwrap(); + assert!(ppp < 2.0, "the frame renders at {ppp} pixels a point"); + // The pan of the canvas's render view, in pixels of its frame. + let pan = |app: &VectorcraftApp| { + let v = app.view().unwrap(); + (Affine::scale(v.zoom * f64::from(ppp)) * Affine::translate(-v.center.to_vec2())).translation() + }; + let whole = |by: vectorcraft_geom::Vec2| (by - vectorcraft_geom::Vec2::new(by.x.round(), by.y.round())).hypot() < 1e-6; + let wheel = |d: egui::Vec2| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: d, + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + let rect = app.canvas_rect.unwrap(); + frame(&mut app, vec![egui::Event::PointerMoved(rect.center())]); + let start = pan(&app); + for (k, d) in [vec2(0.37, 11.13), vec2(-12.71, 0.0), vec2(21.9, -3.83)].into_iter().enumerate() { + frame(&mut app, vec![wheel(d)]); + let by = pan(&app) - start; + assert!(whole(by), "scroll {k}: the view moved {by:?} pixels of the frame"); + } + for k in 0..60 { + frame(&mut app, vec![]); + let by = pan(&app) - start; + assert!(whole(by), "frame {k} after the scrolls: the view moved {by:?} pixels of the frame"); + } + assert!((pan(&app) - start).hypot() > 10.0, "the wheel moved the view"); + app.select_tool("hand"); + let p0 = rect.center() + vec2(0.3, 0.1); + frame(&mut app, vec![egui::Event::PointerMoved(p0)]); + let start = pan(&app); + frame(&mut app, vec![primary(p0, true)]); + for (k, d) in [vec2(13.37, -7.61), vec2(40.19, 3.33)].into_iter().enumerate() { + frame(&mut app, vec![egui::Event::PointerMoved(p0 + d)]); + let by = pan(&app) - start; + assert!(whole(by), "drag {k}: the view moved {by:?} pixels of the frame"); + } + frame(&mut app, vec![primary(p0 + vec2(40.19, 3.33), false)]); +} + /// The part of a device pixel a scroll leaves is added to the next scroll of the same view, and /// dropped when the view was zoomed, turned or moved in between, when the next scroll is in /// another document, or when the device pixels a point changed. diff --git a/crates/ui-egui/src/canvas/tests_ui_scaling.rs b/crates/ui-egui/src/canvas/tests_ui_scaling.rs index d9e2a96edf..335f332529 100644 --- a/crates/ui-egui/src/canvas/tests_ui_scaling.rs +++ b/crates/ui-egui/src/canvas/tests_ui_scaling.rs @@ -10,10 +10,15 @@ use vectorcraft_engine::Session; const WINDOW: egui::Vec2 = vec2(1200.0, 900.0); /// One canvas frame in a `WINDOW` window at `native` device pixels a point, with the interface -/// zoomed by `ui_zoom`. +/// zoomed by `ui_zoom`, on a display whose textures are up to 8,192 pixels a side. fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, (native, ui_zoom): (f32, f32), events: Vec) { ctx.set_zoom_factor(ui_zoom); - let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, WINDOW / ui_zoom)), events, ..Default::default() }; + let mut input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, WINDOW / ui_zoom)), + max_texture_side: Some(8192), + events, + ..Default::default() + }; input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native); let mut out = ctx.run_ui(input, |ui| show(app, ui)); out.textures_delta.clear(); diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 0e43e6a545..4443f12997 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -139,10 +139,18 @@ pub(super) fn whole_pixels(d: egui::Vec2, ppp: f32) -> (f64, f64) { ((f64::from(d.x) * f64::from(ppp)).round(), (f64::from(d.y) * f64::from(ppp)).round()) } +/// The pixels a point that the canvas's art frame renders at ([`super::frame_ppp`]): the display's, +/// or fewer on a canvas wider than the largest texture side. Pans and scrolls move the view by +/// whole pixels of that frame. +pub(super) fn pan_ppp(app: &VectorcraftApp, ui: &Ui) -> f32 { + let ppp = ui.ctx().pixels_per_point(); + app.canvas_rect.map_or(ppp, |r| super::frame_ppp(r.size(), ppp, ui.ctx().input(|i| i.max_texture_side))) +} + /// What the scrolls of the view have left to move ([`scroll_view`]). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub(super) struct ScrollRest { - /// Device pixels along the screen's axes, at most half a pixel each. + /// Pixels of the canvas's frame along the screen's axes, at most half a pixel each. pub(super) x: f64, pub(super) y: f64, /// The document, the view's center, zoom and rotation, and the device pixels a point that the @@ -151,11 +159,11 @@ pub(super) struct ScrollRest { } /// Scroll the active view so that its content moves `by` screen points (the wheel, a trackpad, a -/// touch screen, a scroll bar), in whole device pixels: the part of a pixel left over is added to -/// the next scroll. It is dropped when the view changed in between (a zoom, a turn, Fit, a pan -/// of another kind, another document) or the device pixels a point did. +/// touch screen, a scroll bar), in whole pixels of the canvas's frame ([`pan_ppp`]): the part of a +/// pixel left over is added to the next scroll. It is dropped when the view changed in between (a +/// zoom, a turn, Fit, a pan of another kind, another document) or the pixels a point did. pub(super) fn scroll_view(app: &mut VectorcraftApp, ui: &Ui, by: egui::Vec2) { - let ppp = ui.ctx().pixels_per_point(); + let ppp = pan_ppp(app, ui); let ui_zoom = app.canvas_ui_zoom; let Some(doc) = app.session.active().map(|st| st.uid) else { return }; let Some(vm) = app.view_mut() else { return }; @@ -344,7 +352,7 @@ pub(super) fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, o let hot = drag.is_some() || hover.is_some_and(|p| thumb.contains(p)); painter.rect_filled(thumb, CornerRadius::same(3), if hot { t.text_dim } else { t.text_disabled }); } - let ppp = ui.ctx().pixels_per_point(); + let ppp = pan_ppp(app, ui); if let Some((c, by)) = center && let Some(vm) = app.view_mut() { diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index fc06926247..cd31acd8c9 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -573,7 +573,7 @@ fn dock(app: &mut VectorcraftApp, edge: &str) { fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { let resp = crate::panels::menu_button(ui, "control", rect); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(160.0); + widgets::menu_min_width(ui, 160.0); widgets::menu_scroll(ui, |ui| { for group in CONTROL_BAR_GROUPS { if widgets::menu_item(ui, group_label(group), true, group_on(app, group)) { diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index 31f83c8bba..f8c3033a5e 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -1377,7 +1377,7 @@ pub(crate) fn add_menu_id(name: &str) -> egui::Id { /// bottom bars, as other long menus do ([`widgets::menu_scroll`]). fn add_menu(button: &egui::Response, t: &Tokens, bar: &CustomToolbar, actions: &mut Vec) { egui::Popup::menu(button).id(add_menu_id(&bar.name)).show(|ui| { - ui.set_min_width(200.0); + widgets::menu_min_width(ui, 200.0); widgets::menu_scroll(ui, |ui| { let held: Vec<&'static str> = bar.slots.iter().flatten().filter_map(|id| tool_info(id).map(|t| t.id)).collect(); let heading = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).size(12.0).color(t.text_strong)); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4ac6da2fbe..d958f18c1b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -105,6 +105,7 @@ pub mod relink; pub mod rename_artboards; pub(crate) mod revolve; pub(crate) mod revolve_gizmo; +pub(crate) mod revolve_lights; pub mod safe_mode; pub mod saturate; pub mod save_brush_library; diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index 48f31731cc..bc7dd47212 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -9,11 +9,16 @@ //! Brush), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil and Paintbrush), //! `angle` (degrees) and `roundness` (percent; Blob Brush and Eraser), `livePreview` (Pencil: the stroke shows //! with its fill and stroke while it is drawn, or as a thin line until the release), `size` -//! (points; Blob Brush and Eraser), and `sizeMode` (`fixed` or `pressure`) and `sizeVariation` -//! (points; Blob Brush and Eraser: with Pressure the tip is Size − Variation wide at the lightest -//! pen pressure and Size + Variation at the heaviest). +//! (points; Blob Brush and Eraser), and `sizeMode` and `sizeVariation` (points; Blob Brush and +//! Eraser: with Pressure the tip is Size − Variation wide at the lightest pen pressure and Size + +//! Variation at the heaviest). The Blob Brush's `sizeMode` is `fixed` or `pressure`. The Eraser's +//! Angle, Roundness and Size each have a menu (`angleMode`, `roundnessMode`, `sizeMode`: `fixed`, +//! `random`, `pressure`, `tilt`, `bearing` or `rotation`) before the field and a Variation field +//! under it (`angleVariation` in degrees, `roundnessVariation` in percent, `sizeVariation`), +//! greyed while the menu is Fixed. use serde_json::{Map, Value, json}; +use vectorcraft_brush::Variation; use super::document_setup::check; use super::{DialogSpec, form, run_and_close}; @@ -30,10 +35,14 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_wid pub const TOOLS: [&str; 5] = ["pencil", "paintbrush", "smooth", "blobBrush", "eraser"]; /// The options the dialog edits, in the order it shows them (a tool has those it reports). -const KEYS: [&str; 14] = [ +const KEYS: [&str; 18] = [ "fidelity", "angle", + "angleMode", + "angleVariation", "roundness", + "roundnessMode", + "roundnessVariation", "size", "sizeMode", "sizeVariation", @@ -140,26 +149,58 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), suffix: }); } -/// Width of the Blob Brush's and the Eraser's Size mode menu. +/// Width of the Blob Brush's and the Eraser's variation menus. const MODE_W: f32 = 96.0; -/// The Blob Brush's and the Eraser's Size rows: Size with its Fixed or Pressure menu before the -/// field, then the Variation that Pressure adds and takes (greyed while Size is Fixed). -fn blob_size(ui: &mut egui::Ui, d: &mut Dialog) { - const MODES: [&str; 2] = ["fixed", "pressure"]; - let pressure = d.str("sizeMode") == "pressure"; - let labels = ["Fixed", "Pressure"]; - let label_w = label_w(ui) - MODE_W - ui.spacing().item_spacing.x; - let size = d.f64("size", 10.0); - widgets::label_row(ui, tl!("Size:"), label_w, |ui| { - if let Some(m) = widgets::dropdown(ui, "freehand-size-mode", labels[usize::from(pressure)], &labels, MODE_W).and_then(|i| MODES.get(i)) { - d.fields.insert("sizeMode".into(), json!(m)); +/// A tip value with a variation menu: its field. +struct Varied<'a> { + /// The value's key; the menu's is `Mode` and the Variation's `Variation`. + key: &'a str, + label: &'a str, + unit: &'a str, + decimals: usize, + range: (f64, f64), + /// The value's default. + default: f64, + /// The Variation field's range. + variation: (f64, f64), +} + +/// The rows of a tip value with a variation menu: the value with its menu of `modes` before the +/// field, then the Variation field the menu's modes other than Fixed take (greyed while the +/// menu is Fixed). The Blob Brush's Size offers Fixed and Pressure, and the Eraser's Angle, +/// Roundness and Size every mode. +fn varied(ui: &mut egui::Ui, d: &mut Dialog, v: &Varied, modes: &[Variation]) { + let (mode_key, var_key) = (format!("{}Mode", v.key), format!("{}Variation", v.key)); + let mode = Variation::parse(&d.str(&mode_key)).filter(|m| modes.contains(m)).unwrap_or_default(); + let labels: Vec<&str> = modes.iter().map(|m| m.label()).collect(); + let menu_label_w = label_w(ui) - MODE_W - ui.spacing().item_spacing.x; + let value = d.f64(v.key, v.default); + widgets::label_row(ui, v.label, menu_label_w, |ui| { + if let Some(m) = widgets::dropdown(ui, ("freehand-mode", v.key), mode.label(), &labels, MODE_W).and_then(|i| modes.get(i)) { + d.fields.insert(mode_key.clone(), json!(m.id())); } - if let Some(x) = widgets::range_field(ui, ("freehand-field", "size"), size, 0.1..=1000.0, "pt", 1, FIELD_W) { - d.fields.insert("size".into(), json!(x)); + if let Some(x) = widgets::range_field(ui, ("freehand-field", v.key), value, v.range.0..=v.range.1, v.unit, v.decimals, FIELD_W) { + d.fields.insert(v.key.into(), json!(x)); } }); - ui.add_enabled_ui(pressure, |ui| number(ui, d, ("sizeVariation", tl!("Variation:")), "pt", 1, (0.0, 1000.0))); + ui.add_enabled_ui(mode != Variation::Fixed, |ui| { + let label_w = label_w(ui); + let x = d.f64(&var_key, 0.0); + widgets::label_row(ui, tl!("Variation:"), label_w, |ui| { + if let Some(x) = + widgets::range_field(ui, ("freehand-field", var_key.as_str()), x, v.variation.0..=v.variation.1, v.unit, v.decimals, FIELD_W) + { + d.fields.insert(var_key.clone(), json!(x)); + } + }); + }); +} + +/// The Blob Brush's and the Eraser's Size: [`varied`] in points. +fn size_rows(ui: &mut egui::Ui, d: &mut Dialog, modes: &[Variation]) { + let size = Varied { key: "size", label: tl!("Size:"), unit: "pt", decimals: 1, range: (0.1, 1000.0), default: 10.0, variation: (0.0, 1000.0) }; + varied(ui, d, &size, modes); } /// The Fidelity rows: the label on a line of its own, then a logarithmic slider from the tool's @@ -208,19 +249,39 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { fidelity(ui, d); ui.add_space(10.0); } - if d.fields.contains_key("angle") { - number(ui, d, ("angle", tl!("Angle:")), "°", 0, (-180.0, 180.0)); - } - if d.fields.contains_key("roundness") { - let min = if d.str("tool") == "blobBrush" { 0.0 } else { 1.0 }; - number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (min, 100.0)); + let eraser = d.fields.contains_key("angleMode"); + if eraser { + // The Eraser: Angle, Roundness and Size each with every variation mode. + let angle = + Varied { key: "angle", label: tl!("Angle:"), unit: "°", decimals: 0, range: (-180.0, 180.0), default: 0.0, variation: (0.0, 180.0) }; + varied(ui, d, &angle, &Variation::ALL); + let roundness = Varied { + key: "roundness", + label: tl!("Roundness:"), + unit: "%", + decimals: 0, + range: (1.0, 100.0), + default: 100.0, + variation: (0.0, 100.0), + }; + varied(ui, d, &roundness, &Variation::ALL); + size_rows(ui, d, &Variation::ALL); + } else { + if d.fields.contains_key("angle") { + number(ui, d, ("angle", tl!("Angle:")), "°", 0, (-180.0, 180.0)); + } + if d.fields.contains_key("roundness") { + let min = if d.str("tool") == "blobBrush" { 0.0 } else { 1.0 }; + number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (min, 100.0)); + } } if d.str("tool") == "blobBrush" { blob_tip_preview(ui, d); } - if d.fields.contains_key("sizeMode") { - blob_size(ui, d); - } else if d.fields.contains_key("size") { + // The Eraser's Size rows are above. + if !eraser && d.fields.contains_key("sizeMode") { + size_rows(ui, d, &[Variation::Fixed, Variation::Pressure]); + } else if !eraser && d.fields.contains_key("size") { number(ui, d, ("size", tl!("Size:")), "pt", 1, (0.1, 1000.0)); } if d.fields.contains_key("fill") { diff --git a/crates/ui-egui/src/dialogs/freehand/tests.rs b/crates/ui-egui/src/dialogs/freehand/tests.rs index 35d216bc77..1b41fc0886 100644 --- a/crates/ui-egui/src/dialogs/freehand/tests.rs +++ b/crates/ui-egui/src/dialogs/freehand/tests.rs @@ -366,6 +366,38 @@ fn the_blob_brush_and_eraser_size_can_follow_the_pen_pressure() { assert!(!text.contains("Variation:") && !text.contains("Fixed"), "{text}"); } +/// Eraser Tool Options gives Angle, Roundness and Size each a variation menu with a Variation +/// field; OK sets the modes and Variations. The Blob Brush keeps one menu, on Size. +#[test] +fn the_eraser_angle_roundness_and_size_each_have_a_variation_menu() { + let mut app = app(); + app.run("tool.options", json!({"tool": "eraser"})).unwrap(); + let text = painted(&mut app); + assert_eq!((text.matches("Variation:").count(), text.matches("Fixed").count()), (3, 3), "{text}"); + let d = app.ui.dialog.as_mut().unwrap(); + for (k, v) in [ + ("angleMode", json!("bearing")), + ("angleVariation", json!(30)), + ("roundnessMode", json!("random")), + ("roundnessVariation", json!(20)), + ("sizeMode", json!("rotation")), + ("sizeVariation", json!(5)), + ] { + d.fields.insert(k.into(), v); + } + let text = painted(&mut app); + assert!(text.contains("Bearing") && text.contains("Random") && text.contains("Rotation") && !text.contains("Fixed"), "{text}"); + super::super::confirm(&mut app).unwrap(); + let o = app.session.tool_options_of("eraser"); + assert_eq!( + [&o["angleMode"], &o["angleVariation"], &o["roundnessMode"], &o["roundnessVariation"], &o["sizeMode"], &o["sizeVariation"]], + [&json!("bearing"), &json!(30.0), &json!("random"), &json!(20.0), &json!("rotation"), &json!(5.0)] + ); + app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); + let text = painted(&mut app); + assert_eq!(text.matches("Variation:").count(), 1, "{text}"); +} + #[test] fn the_eraser_options_set_the_tips_angle_and_roundness() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index de21c368bc..a547c8fbb7 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -362,6 +362,10 @@ fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) { // A mixed Shading leaves the light settings on, for the effects that shade. ui.add_enabled_ui(flag(d, "shade", true) != Some(false), |ui| { lighting_presets(ui, d); + // The panel's lighting widget; the dialog moves lights with its rings on the artboard. + if d.bool("__panel") { + super::revolve_lights::show(ui, d); + } light_list(ui, d); let mut selected = selected_light(d); let before = light_record(&selected); @@ -429,7 +433,7 @@ const LIGHT_KEYS: [(&str, &str); 6] = [ ("behind", "lightBehind"), ]; -fn explicit_lights(d: &Dialog) -> Option<&Vec> { +pub(super) fn explicit_lights(d: &Dialog) -> Option<&Vec> { d.fields.get("lights").and_then(Value::as_array).filter(|v| !v.is_empty() && v.len() <= vectorcraft_three_d::MAX_LIGHTS) } diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index 4595447ac2..c99e7ab83e 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -186,13 +186,13 @@ struct Drag { fields: serde_json::Map, } -fn wrap(n: f64, snap: bool) -> f64 { +pub(super) fn wrap(n: f64, snap: bool) -> f64 { let n = if snap { (n / 15.0).round() * 15.0 } else { n }; (n + 180.0).rem_euclid(360.0) - 180.0 } /// A Height, 0° to 90°, on 15° steps with Shift. -fn snap_height(height: f64, snap: bool) -> f64 { +pub(super) fn snap_height(height: f64, snap: bool) -> f64 { (if snap { (height / 15.0).round() * 15.0 } else { height }).clamp(0.0, 90.0) } diff --git a/crates/ui-egui/src/dialogs/revolve_lights.rs b/crates/ui-egui/src/dialogs/revolve_lights.rs new file mode 100644 index 0000000000..1a4116b7ee --- /dev/null +++ b/crates/ui-egui/src/dialogs/revolve_lights.rs @@ -0,0 +1,261 @@ +//! The lighting widget on the 3D and Materials panel's Lighting tab for Extrude & Bevel, Revolve, +//! Inflate and Rotate: a sphere that shows each of the effect's lights +//! at its Rotation and Height, a filled sun in front of the object and a hollow one behind it. +//! Dragging a light moves it on its side of the object, a press selects it in the list of lights, +//! and the button below the sphere moves the selected light to the back or the front of the object. +//! Each light takes the keyboard focus, where the arrow keys move it, and has a name for screen +//! readers. The widget edits the panel's fields as the light settings do, so the panel previews a +//! drag and records it as one undo step when the pointer is released. + +use egui::{Id, Key, Modifiers, Rect, Sense, Stroke, Vec2, vec2}; +use serde_json::{Value, json}; +use vectorcraft_geom::Point; +use vectorcraft_three_d::{DEFAULT_LIGHT, Revolve}; + +use super::revolve::{c3, explicit_lights, light_record, set_light_value}; +use super::revolve_gizmo::{snap_height, wrap}; +use crate::state::Dialog; +use crate::theme::Tokens; + +/// The sphere's largest radius, in points. +pub(crate) const RADIUS: f32 = 56.0; +/// The radius of a light's sun. +const SUN: f32 = 7.0; +/// The arrow keys' steps in degrees, without and with Shift. +const STEP: f64 = 1.0; +const SHIFT_STEP: f64 = 15.0; +/// The keys that move the focused light. +const ARROWS: [Key; 4] = [Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown]; + +/// One light as the widget shows it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Light { + pub rotation: f64, + pub height: f64, + pub behind: bool, + pub color: egui::Color32, +} + +impl Light { + fn options(self) -> Revolve { + Revolve { light_rotation: self.rotation, light_height: self.height, light_behind: self.behind, ..Default::default() } + } + + /// Where the light sits on a sphere of radius 1 around the origin, y down: the direction the + /// shading uses, seen from the front. + pub(crate) fn offset(self) -> Vec2 { + let [x, y, _] = self.options().light_direction(); + vec2(x as f32, y as f32) + } + + /// The Rotation and Height, rounded to 0.1°, of the light moved to `offset` (radius 1, y down) + /// on its side of the object: the farther out, the lower. Shift (`snap`) snaps both to 15°. + pub(crate) fn moved_to(self, offset: Vec2, snap: bool) -> (f64, f64) { + let [rotation, height] = self.options().light_at_on_side(Point::new(f64::from(offset.x), f64::from(offset.y))); + (round(wrap(rotation, snap)), round(snap_height(height, snap))) + } + + /// The Rotation and Height after one press of `key` (Left and Right turn the light around the + /// object counterclockwise and clockwise, Up and Down raise and lower it). Only the setting the + /// key moves changes, rounded to 0.1°. `None` for another key. + fn stepped(self, key: Key, step: f64) -> Option<(f64, f64)> { + let turn = |by: f64| round(wrap(self.rotation + by, false)); + let raise = |by: f64| round(snap_height(self.height + by, false)); + match key { + Key::ArrowLeft => Some((turn(step), self.height)), + Key::ArrowRight => Some((turn(-step), self.height)), + Key::ArrowUp => Some((self.rotation, raise(step))), + Key::ArrowDown => Some((self.rotation, raise(-step))), + _ => None, + } + } + + /// The name screen readers give the light at `index`: its number, Rotation, Height and side. + fn name(self, index: usize) -> String { + let side = if self.behind { format!(", {}", tl!("Light behind the object")) } else { String::new() }; + format!("{} {}: {} {:.1}°, {} {:.1}°{side}", c3("Light"), index + 1, tl!("Rotation"), self.rotation, tl!("Height"), self.height) + } +} + +fn round(n: f64) -> f64 { + (n * 10.0).round() / 10.0 +} + +/// The effect's lights: each light of its list, or its single light, with the effect's defaults for +/// the settings a light leaves out or the selected effects hold with different values (null). +pub(crate) fn lights(d: &Dialog) -> Vec { + let defaults = vectorcraft_effects::default_params(&d.str("__effect")).unwrap_or(json!({})); + let list = explicit_lights(d).cloned().unwrap_or_else(|| vec![light_record(d)]); + list.iter() + .map(|light| { + let value = + |key: &str, field: &str| light.get(key).filter(|v| !v.is_null()).or_else(|| defaults.get(field)).cloned().unwrap_or(Value::Null); + let [r, g, b] = value("color", "lightColor") + .as_str() + .filter(|s| s.len() == 7 && s.starts_with('#')) + .and_then(vectorcraft_color::Color::from_hex) + .unwrap_or(vectorcraft_color::Color::WHITE) + .to_rgb_uncalibrated() + .map(|c| (c * 255.0).round() as u8); + Light { + rotation: value("rotation", "lightRotation").as_f64().unwrap_or(DEFAULT_LIGHT.0), + height: value("height", "lightHeight").as_f64().unwrap_or(DEFAULT_LIGHT.1), + behind: value("behind", "lightBehind").as_bool().unwrap_or(false), + color: egui::Color32::from_rgb(r, g, b), + } + }) + .collect() +} + +/// The index of the selected light among `count` lights. +fn selected(d: &Dialog, count: usize) -> usize { + d.fields.get("__light").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).unwrap_or(0).min(count.saturating_sub(1)) +} + +/// Makes light `index` the selected one, as a choice in the list of lights does. +fn select(ui: &egui::Ui, d: &mut Dialog, index: usize) { + if d.fields.get("__light").and_then(Value::as_u64) != Some(index as u64) { + ui.memory_mut(|m| m.stop_text_input()); + super::revolve_gizmo::clear(ui.ctx()); + } + d.fields.insert("__light".into(), json!(index)); +} + +/// Draws the sphere with the lights and the button that moves the selected light to the back or +/// the front, editing `d.fields`. +pub(super) fn show(ui: &mut egui::Ui, d: &mut Dialog) { + let lights = lights(d); + let Some(&first) = lights.first() else { return }; + let current = selected(d, lights.len()); + let t = Tokens::get(ui.ctx()); + let radius = RADIUS.min((ui.available_width() * 0.5 - SUN - 8.0).max(24.0)); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), (radius + SUN + 6.0) * 2.0), Sense::hover()); + let center = rect.center(); + let painter = ui.painter_at(rect); + // The sphere: its rim is Height 0, its center Height 90°, and the ring between them 45°. + painter.circle(center, radius, t.input, Stroke::new(1.0, t.input_border)); + painter.circle_stroke(center, radius * std::f32::consts::FRAC_1_SQRT_2, Stroke::new(1.0, t.input_border.gamma_multiply(0.5))); + for axis in [vec2(radius, 0.0), vec2(0.0, radius)] { + painter.line_segment([center - axis, center + axis], Stroke::new(1.0, t.input_border.gamma_multiply(0.5))); + } + painter.circle(center, 4.0, t.panel, Stroke::new(1.0, t.text_dim)); + // The selected light last, so it is drawn above the others and a press there takes it. + let order = (0..lights.len()).filter(|&i| i != current).chain([current]); + let mut moved = None; + let enabled = ui.is_enabled(); + for index in order { + let light = lights.get(index).copied().unwrap_or(first); + let at = center + light.offset() * radius; + let id = Id::unique(("three-d-lighting-widget", index)); + let response = ui.interact(Rect::from_center_size(at, Vec2::splat((SUN + 4.0) * 2.0)), id, Sense::click_and_drag()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, enabled, light.name(index))); + // A light the pointer took, or that the keyboard focus reached, becomes the selected light and + // keeps the focus, so the arrow keys move it next. Selecting another light stops the editor + // that had the focus, which clears the focus, so the light asks for it after that. + if response.drag_started() || response.clicked() || response.gained_focus() { + select(ui, d, index); + response.request_focus(); + } + let origin = ui.input(|i| i.pointer.press_origin()); + if response.drag_started() + && let Some(origin) = origin + { + // The offset from the press to the light's center, kept for the drag, and whether the + // pointer has left the press yet. + ui.data_mut(|m| m.insert_temp(id, (at - origin, false))); + } + // The frame that releases the pointer can also move it: the light goes where it was dropped, + // the point of the press included once the pointer has left it. + if (response.dragged() || response.drag_stopped()) + && let Some(p) = response.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())) + { + let (grip, left): (Vec2, bool) = ui.data(|m| m.get_temp(id)).unwrap_or_default(); + let left = left || origin != Some(p); + ui.data_mut(|m| m.insert_temp(id, (grip, left))); + if left { + let snap = ui.input(|i| i.modifiers.shift); + moved = Some((index, light.moved_to((p + grip - center) / radius, snap))); + } + } + if response.has_focus() { + ui.memory_mut(|m| { + m.set_focus_lock_filter(id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + }); + // Every arrow-key press in the frame moves the light in turn, in the order it came. + let presses = ui.input_mut(|i| { + let mut presses = vec![]; + i.events.retain(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } + if ARROWS.contains(key) && (modifiers.matches_exact(Modifiers::NONE) || modifiers.matches_exact(Modifiers::SHIFT)) => + { + presses.push((*key, if modifiers.shift { SHIFT_STEP } else { STEP })); + false + } + _ => true, + }); + presses + }); + let mut next = light; + for (key, step) in presses { + if let Some((rotation, height)) = next.stepped(key, step) { + (next.rotation, next.height) = (rotation, height); + } + } + if next != light { + moved = Some((index, (next.rotation, next.height))); + } + } + paint_light(ui, at, light, index == current, response.has_focus(), response.hovered() || response.dragged()); + if response.hovered() || response.dragged() { + ui.ctx().set_cursor_icon(if response.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); + } + response.on_hover_text(tl!("Drag a light to move it. The arrow keys move the focused light; Shift steps 15°.")); + } + // Only a setting that moved is written: a press that leaves a light where it is, or a key at a + // limit, changes nothing, and a key leaves the other setting as each selected effect has it. + if let Some((index, (rotation, height))) = moved + && let Some(light) = lights.get(index) + { + for (key, value, was) in [("lightRotation", rotation, light.rotation), ("lightHeight", height, light.height)] { + if (value - was).abs() > 1e-9 { + select(ui, d, index); + set_light_value(d, key, json!(value)); + } + } + } + let index = selected(d, lights.len()); + let behind = lights.get(index).is_some_and(|l| l.behind); + ui.horizontal(|ui| { + let text = if behind { tl!("Move Light to Front") } else { tl!("Move Light to Back") }; + if ui.button(text).clicked() { + select(ui, d, index); + set_light_value(d, "lightBehind", json!(!behind)); + } + }); + ui.add_space(4.0); +} + +/// A light's sun: filled with its color in front of the object, hollow behind it, larger and +/// ringed when it is the selected light, with a second ring while it has the keyboard focus. +fn paint_light(ui: &egui::Ui, at: egui::Pos2, light: Light, selected: bool, focused: bool, hot: bool) { + let t = Tokens::get(ui.ctx()); + let painter = ui.painter(); + let r = if selected { SUN } else { SUN - 2.0 }; + let outline = Stroke::new(if hot { 2.5 } else { 1.5 }, if selected { t.warning } else { t.warning.gamma_multiply(0.7) }); + if light.behind { + painter.circle(at, r, t.panel, outline); + painter.circle_stroke(at, r * 0.5, Stroke::new(1.5, light.color)); + } else { + painter.circle(at, r, light.color, outline); + } + if selected { + for i in 0..8 { + let angle = i as f32 * std::f32::consts::TAU / 8.0; + let direction = vec2(angle.cos(), angle.sin()); + painter.line_segment([at + direction * (r + 2.0), at + direction * (r + 4.0)], Stroke::new(1.25, t.warning)); + } + } + if focused { + painter.circle_stroke(at, r + 6.0, Stroke::new(1.0, t.accent)); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e455157e14..2f43999f7c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3327,6 +3327,7 @@ digits Malayalam Малаялам Sixth Space Шесто разстояние Size Размер Size Tile to Art Оразмеряване на плочката според обектите + Move Tile with Art Преместване на плочката заедно с обектите Size/Leading Размер/междуредие Size: Размер: Ridges per segment: Изпъкналости на сегмент: @@ -3955,6 +3956,9 @@ digits Malayalam Малаялам Working CMYK Работен CMYK Working CMYK (don't tag) Работен CMYK (без етикет) Working RGB (don't tag) Работен RGB (без етикет) + Don't Color Manage This Document Без управление на цветовете за този документ + Working RGB: {name} Работен RGB: {name} + Working CMYK: {name} Работен CMYK: {name} Workspace Работно пространство Workspace {i} Работно пространство {i} Wrinkle Tool Инструмент „Набръчкване“ @@ -5711,6 +5715,9 @@ theme Appearance Външен вид Softness Мекота Ambient Light Околна светлина Light behind the object Светлина зад обекта + Move Light to Back Премести светлината отзад + Move Light to Front Премести светлината отпред + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Плъзнете светлина, за да я преместите. Клавишите със стрелки местят фокусираната светлина; Shift я мести с 15°. Shadows Сенки Distance from Object Разстояние от обекта Shadow Bounds Граници на сянката @@ -6254,6 +6261,13 @@ axis Y Axis Y ос Graphite Pencil Графитен молив Wax Crayon Восъчен пастел Charcoal Dust Въглен прах + +# Original procedural circuit brushes. + Tech Circuit Технологични схеми + Circuit Trace Проводяща писта + Bus Lines Шинни линии + Terminal Nodes Крайни възли + Notched Ribbon Назъбена лента Double Line Двойна линия Wave Вълна diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f126736293..60c5705497 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2103,6 +2103,7 @@ digits Malayalam Malajalam Show Swatch Bounds Prikaži granice uzorka boje Show Tile Edge Prikaži ivicu pločice Size Tile to Art Prilagodi pločicu crtežu + Move Tile with Art Pomjeri pločicu s crtežom Tile Type: Vrsta pločice: Top in Front Vrh naprijed V Spacing: Okomiti razmak: @@ -2145,6 +2146,9 @@ digits Malayalam Malajalam Use Black Point Compensation Koristi kompenzaciju crne tačke Working CMYK (don't tag) Radni CMYK (bez oznake) Working RGB (don't tag) Radni RGB (bez oznake) + Don't Color Manage This Document Ne upravljaj bojama ovog dokumenta + Working RGB: {name} Radni RGB: {name} + Working CMYK: {name} Radni CMYK: {name} {name} (spot) {name} (spot) Add to Profiles… Dodaj u profile… Align Stroke to Center Poravnaj potez po sredini @@ -5797,6 +5801,9 @@ mark-style Japanese Japanski Softness Mekoća Ambient Light Ambijentalno svjetlo Light behind the object Svjetlo iza objekta + Move Light to Back Pomjeri svjetlo iza + Move Light to Front Pomjeri svjetlo naprijed + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Prevucite svjetlo da ga pomjerite. Tipke sa strelicama pomjeraju fokusirano svjetlo; Shift ga pomjera za 15°. Shadows Sjene Distance from Object Udaljenost od objekta Shadow Bounds Granice sjene @@ -6307,6 +6314,13 @@ axis Y Axis Y osa Graphite Pencil Grafitna olovka Wax Crayon Voštana bojica Charcoal Dust Ugljena prašina + +# Original procedural circuit brushes. + Tech Circuit Tehnički sklopovi + Circuit Trace Vod sklopa + Bus Lines Linije sabirnice + Terminal Nodes Priključni čvorovi + Notched Ribbon Zarezana traka Double Line Dvostruka linija Wave Talas diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9dc7bef3cf..db5f5b0aee 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2339,6 +2339,9 @@ digits Malayalam Malaiàlam Ligatures are off while the text is tracked. Les lligadures estan desactivades mentre el text té espaiat. Light Clar Light behind the object Llum darrere de l’objecte + Move Light to Back Mou la llum al darrere + Move Light to Front Mou la llum al davant + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Arrossegueu una llum per moure-la. Les tecles de fletxa mouen la llum amb el focus; Maj la mou de 15° en 15°. Shadows Ombres Distance from Object Distància de l’objecte Shadow Bounds Límits de l’ombra @@ -3972,6 +3975,13 @@ digits Malayalam Malaiàlam Graphite Pencil Llapis de grafit Wax Crayon Cera de colors Charcoal Dust Pols de carbó + +# Original procedural circuit brushes. + Tech Circuit Circuits tecnològics + Circuit Trace Pista de circuit + Bus Lines Línies de bus + Terminal Nodes Nodes terminals + Notched Ribbon Cinta dentada Double Line Línia doble Wave Ona @@ -4342,6 +4352,7 @@ digits Malayalam Malaiàlam Sixth Space Espai de sisena part Size Mida Size Tile to Art Ajusta la mida del mosaic a l’art + Move Tile with Art Mou el mosaic amb l’art Size/Leading Mida/Interlineat Size: Mida: Size: {size} Tracking: {tracking} Mida: {size} Espaiat: {tracking} @@ -5154,6 +5165,9 @@ digits Malayalam Malaiàlam Working CMYK CMYK de treball Working CMYK (don't tag) CMYK de treball (sense etiquetar) Working RGB (don't tag) RGB de treball (sense etiquetar) + Don't Color Manage This Document No gestionis el color d'aquest document + Working RGB: {name} RGB de treball: {name} + Working CMYK: {name} CMYK de treball: {name} Working RGB RGB de treball Workspace Espai de treball Workspace {i} Espai de treball {i} @@ -5433,6 +5447,9 @@ digits Malayalam Malaiàlam @msg automation path rejected: cannot resolve {path}: {e} camí d’automatització rebutjat: no es pot resoldre {path}: {e} @msg automation path rejected: outside the read root {root}: {path} camí d’automatització rebutjat: fora de l’arrel de lectura {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} camí d’automatització rebutjat: fora de les arrels de lectura {roots}: {path} +@msg a linked JavaScript file is outside the read roots un fitxer JavaScript enllaçat és fora de les arrels de lectura +@msg the library's file is outside the read roots el fitxer de la biblioteca és fora de les arrels de lectura +@msg {file} names more than one linked JavaScript file {file} correspon a més d’un fitxer JavaScript enllaçat @msg automation path rejected: outside the write root {root}: {path} camí d’automatització rebutjat: fora de l’arrel d’escriptura {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} camí d’automatització rebutjat: fora de les arrels d’escriptura {roots}: {path} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} camí d’automatització rebutjat: recorregut ascendent per sota d’una carpeta que no existeix: {path} @@ -5858,6 +5875,7 @@ digits Malayalam Malaiàlam @msg no library `{key}` (see {list}) no hi ha la biblioteca `{key}` (vegeu {list}) @msg No linked files to look for No hi ha fitxers enllaçats per buscar @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas L’enllaç és dins d’un símbol, un disseny de gràfic, un motiu o una màscara d’opacitat i no té lloc al llenç +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it la il·lustració d’un disseny de gràfic en una sèrie d’un gràfic es torna a dibuixar a partir del disseny: editeu el disseny per canviar-la @msg no Live Paint edge near that point no hi ha cap aresta de pintura interactiva a prop d’aquell punt @msg no Live Paint face at that point no hi ha cap cara de pintura interactiva en aquell punt @msg no native format no hi ha format natiu diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d82f5628c5..435ec1e1aa 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2134,6 +2134,7 @@ digits Malayalam Malajálamské Show Swatch Bounds Zobrazit hranice položky vzorníku Show Tile Edge Zobrazit okraj dlaždice Size Tile to Art Velikost dlaždice podle kresby + Move Tile with Art Přesouvat dlaždici s kresbou Tile Type: Typ dlaždice: Top in Front Horní vpředu V Spacing: Svislá mezera: @@ -2176,6 +2177,9 @@ digits Malayalam Malajálamské Use Black Point Compensation Použít kompenzaci černého bodu Working CMYK (don't tag) Pracovní CMYK (neoznačovat) Working RGB (don't tag) Pracovní RGB (neoznačovat) + Don't Color Manage This Document Nespravovat barvy tohoto dokumentu + Working RGB: {name} Pracovní RGB: {name} + Working CMYK: {name} Pracovní CMYK: {name} {name} (spot) {name} (přímá) Add to Profiles… Přidat do profilů… Align Stroke to Center Zarovnat tah na střed @@ -5520,6 +5524,7 @@ imageTrace Outline Obrys @msg every numbered name for {name} is taken in the document's folder ve složce dokumentu jsou obsazena všechna číslovaná jména pro {name} @msg No linked files to look for Žádné propojené soubory k vyhledání @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Propojení je uvnitř symbolu, návrhu grafu, vzorku nebo masky krytí a nemá na plátně žádné místo +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it kresba návrhu grafu v datové řadě grafu se znovu vykresluje podle návrhu: chcete-li ji změnit, upravte návrh @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` je odkaz na krok {r}, který vyhodnocují jen `run` a `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musí být pole ID objektů, ne {v} @msg `colors` must be an array of colors, not {v} `colors` musí být pole barev, ne {v} @@ -5539,6 +5544,7 @@ imageTrace Outline Obrys @msg automation filesystem access is not granted: write authority is absent: {path} přístup automatizace k systému souborů není udělen: chybí oprávnění k zápisu: {path} @msg automation path rejected: outside the read root {root}: {path} cesta automatizace odmítnuta: mimo kořen pro čtení {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} cesta automatizace odmítnuta: mimo kořeny pro čtení {roots}: {path} +@msg a linked JavaScript file is outside the read roots propojený soubor JavaScript je mimo kořeny pro čtení @msg automation path rejected: outside the write root {root}: {path} cesta automatizace odmítnuta: mimo kořen pro zápis {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} cesta automatizace odmítnuta: mimo kořeny pro zápis {roots}: {path} @msg automation path rejected: path is empty cesta automatizace odmítnuta: cesta je prázdná @@ -6100,6 +6106,9 @@ mark-style Japanese Japonské Softness Měkkost Ambient Light Okolní světlo Light behind the object Světlo za objektem + Move Light to Back Přesunout světlo dozadu + Move Light to Front Přesunout světlo dopředu + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Přetažením světlo přesunete. Šipky posouvají světlo s fokusem; se Shiftem po 15°. Shadows Stíny Distance from Object Vzdálenost od objektu Shadow Bounds Hranice stínu @@ -6811,6 +6820,13 @@ axis Y Axis osa Y Graphite Pencil Grafitová tužka Wax Crayon Vosková pastelka Charcoal Dust Uhlový prach + +# Original procedural circuit brushes. + Tech Circuit Technické obvody + Circuit Trace Vodivá cesta + Bus Lines Sběrnicové linky + Terminal Nodes Koncové uzly + Notched Ribbon Vroubkovaná stuha Double Line Dvojitá čára Wave Vlna diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f327d93098..0d62adc6a3 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2543,6 +2543,7 @@ digits Malayalam Malayalam Show Swatch Bounds Vis farveprøvegrænser Show Tile Edge Vis flisekant Size Tile to Art Tilpas flise til grafik + Move Tile with Art Flyt flise med grafik Tile Type: Flisetype: Top in Front Toppen forrest V Spacing: Lodret afstand: @@ -2585,6 +2586,9 @@ digits Malayalam Malayalam Use Black Point Compensation Brug sortpunktskompensation Working CMYK (don't tag) Arbejds-CMYK (mærk ikke) Working RGB (don't tag) Arbejds-RGB (mærk ikke) + Don't Color Manage This Document Farvestyr ikke dette dokument + Working RGB: {name} Arbejds-RGB: {name} + Working CMYK: {name} Arbejds-CMYK: {name} {name} (spot) {name} (spotfarve) Add to Profiles… Tilføj til profiler… Align Stroke to Center Justér kontur til midten @@ -6054,6 +6058,9 @@ mark-style Japanese Japansk Softness Blødhed Ambient Light Omgivelseslys Light behind the object Lys bag objektet + Move Light to Back Flyt lys bagud + Move Light to Front Flyt lys frem + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Træk et lys for at flytte det. Piletasterne flytter lyset med fokus; Skift flytter det 15° ad gangen. Shadows Skygger Distance from Object Afstand fra objektet Shadow Bounds Skyggegrænser @@ -6880,6 +6887,13 @@ axis Y Axis Y-akse Graphite Pencil Grafitblyant Wax Crayon Vokskridt Charcoal Dust Kulstøv + +# Original procedural circuit brushes. + Tech Circuit Tekniske kredsløb + Circuit Trace Kredsløbsbane + Bus Lines Buslinjer + Terminal Nodes Terminalpunkter + Notched Ribbon Takket bånd Double Line Dobbelt linje Wave Bølge diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2730b5fa4c..c48259f6f1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3389,6 +3389,7 @@ digits Malayalam Malayalam Sixth Space Sechstelgeviert-Leerzeichen Size Größe Size Tile to Art Kachelgröße an Grafik anpassen + Move Tile with Art Kachel mit Grafik verschieben Size/Leading Größe/Zeilenabstand Size: Größe: Ridges per segment: Zacken pro Segment: @@ -3965,6 +3966,9 @@ effect Points Punkte Working CMYK CMYK-Arbeitsfarbraum Working CMYK (don't tag) CMYK-Arbeitsfarbraum (nicht kennzeichnen) Working RGB (don't tag) RGB-Arbeitsfarbraum (nicht kennzeichnen) + Don't Color Manage This Document Kein Farbmanagement für dieses Dokument + Working RGB: {name} RGB-Arbeitsfarbraum: {name} + Working CMYK: {name} CMYK-Arbeitsfarbraum: {name} Workspace Arbeitsbereich Workspace {i} Arbeitsbereich {i} Wrinkle Tool Zerknittern-Werkzeug @@ -5682,6 +5686,9 @@ effect Soft Weich Softness Weichheit Ambient Light Umgebungslicht Light behind the object Licht hinter dem Objekt + Move Light to Back Licht nach hinten + Move Light to Front Licht nach vorne + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Ziehen Sie ein Licht, um es zu verschieben. Die Pfeiltasten verschieben das fokussierte Licht; mit Umschalt in Schritten von 15°. Shadows Schatten Distance from Object Abstand vom Objekt Shadow Bounds Schattenbegrenzung @@ -6403,6 +6410,13 @@ axis Y Axis Y-Achse Graphite Pencil Graphitstift Wax Crayon Wachsmalstift Charcoal Dust Kohlestaub + +# Original procedural circuit brushes. + Tech Circuit Technische Schaltungen + Circuit Trace Leiterbahn + Bus Lines Busleitungen + Terminal Nodes Anschlussknoten + Notched Ribbon Gekerbtes Band Double Line Doppellinie Wave Welle diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5bd9bac4e6..95b41e7739 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3972,6 +3972,7 @@ digits Malayalam Μαλαγιάλαμ Toggle Line Composer Εναλλαγή σύνθεσης γραμμών Sixth Space Διάστημα ενός έκτου Size Tile to Art Προσαρμογή μεγέθους πλακιδίου στο σχέδιο + Move Tile with Art Μετακίνηση πλακιδίου μαζί με το σχέδιο Size/Leading Μέγεθος/διάστιχο Ridges per segment: Ράχες ανά τμήμα: Size: {size} Tracking: {tracking} Μέγεθος: {size} Απόσταση χαρακτήρων: {tracking} @@ -4433,6 +4434,9 @@ digits Malayalam Μαλαγιάλαμ Width Tool Πλάτος (Width Tool) Working CMYK (don't tag) Χώρος εργασίας CMYK (χωρίς ετικέτα) Working RGB (don't tag) Χώρος εργασίας RGB (χωρίς ετικέτα) + Don't Color Manage This Document Χωρίς διαχείριση χρωμάτων για αυτό το έγγραφο + Working RGB: {name} Χώρος εργασίας RGB: {name} + Working CMYK: {name} Χώρος εργασίας CMYK: {name} Workspace {i} Χώρος εργασίας {i} Wrinkle Tool Πτυχώσεις (Wrinkle Tool) Write Layers Εγγραφή επιπέδων @@ -5162,6 +5166,7 @@ digits Malayalam Μαλαγιάλαμ @msg every numbered name for {name} is taken in the document's folder όλα τα αριθμημένα ονόματα για το {name} χρησιμοποιούνται ήδη στον φάκελο εγγράφου @msg No linked files to look for Δεν υπάρχουν συνδεδεμένα αρχεία για αναζήτηση @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Ο σύνδεσμος βρίσκεται μέσα σε σύμβολο, σχεδιασμό γραφήματος, μοτίβο ή μάσκα αδιαφάνειας και δεν έχει θέση στον καμβά +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it το έργο ενός σχεδιασμού γραφήματος σε μια σειρά γραφήματος σχεδιάζεται ξανά από τον σχεδιασμό: επεξεργαστείτε τον σχεδιασμό για να το αλλάξετε Hide VectorCraft Απόκρυψη VectorCraft Bring All to Front Μεταφορά όλων στο προσκήνιο Break Link Κατάργηση σύνδεσης @@ -5219,6 +5224,9 @@ digits Malayalam Μαλαγιάλαμ @msg automation filesystem access is not granted: write authority is absent: {path} δεν έχει επιτραπεί πρόσβαση αυτοματισμού στο σύστημα αρχείων: δεν υπάρχει δικαίωμα εγγραφής: {path} @msg automation path rejected: outside the read root {root}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός ρίζας ανάγνωσης {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός των ριζών ανάγνωσης {roots}: {path} +@msg a linked JavaScript file is outside the read roots ένα συνδεδεμένο αρχείο JavaScript είναι εκτός των ριζών ανάγνωσης +@msg the library's file is outside the read roots το αρχείο της βιβλιοθήκης είναι εκτός των ριζών ανάγνωσης +@msg {file} names more than one linked JavaScript file το {file} αντιστοιχεί σε περισσότερα από ένα συνδεδεμένα αρχεία JavaScript @msg automation path rejected: outside the write root {root}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός ρίζας εγγραφής {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός των ριζών εγγραφής {roots}: {path} @msg automation path rejected: path is empty απορρίφθηκε η διαδρομή αυτοματισμού: η διαδρομή είναι κενή @@ -5779,6 +5787,9 @@ digits Malayalam Μαλαγιάλαμ to set spacing and orientation για ορισμό απόστασης και προσανατολισμού : : Light behind the object Φως πίσω από το αντικείμενο + Move Light to Back Μετακίνηση φωτός πίσω + Move Light to Front Μετακίνηση φωτός μπροστά + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Σύρετε ένα φως για να το μετακινήσετε. Τα πλήκτρα βέλους μετακινούν το εστιασμένο φως· με Shift ανά 15°. Shadows Σκιές Distance from Object Απόσταση από το αντικείμενο Shadow Bounds Όρια σκιάς @@ -6765,6 +6776,13 @@ axis Y Axis Άξονας Y Graphite Pencil Μολύβι γραφίτη Wax Crayon Κηρομπογιά Charcoal Dust Σκόνη κάρβουνου + +# Original procedural circuit brushes. + Tech Circuit Τεχνολογικά κυκλώματα + Circuit Trace Διαδρομή κυκλώματος + Bus Lines Γραμμές διαύλου + Terminal Nodes Τερματικοί κόμβοι + Notched Ribbon Οδοντωτή κορδέλα Double Line Διπλή γραμμή Wave Κύμα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c4dc5a6297..e99d4d8d3d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3420,6 +3420,7 @@ digits Malayalam Malayálam Sixth Space Sexto de espacio Size Tamaño Size Tile to Art Ajustar tamaño del mosaico a la ilustración + Move Tile with Art Mover mosaico con la ilustración Size/Leading Tamaño/interlineado Size: Tamaño: Ridges per segment: Crestas por segmento: @@ -4047,6 +4048,9 @@ digits Malayalam Malayálam Working CMYK CMYK de trabajo Working CMYK (don't tag) CMYK de trabajo (sin etiquetar) Working RGB (don't tag) RGB de trabajo (sin etiquetar) + Don't Color Manage This Document No gestionar el color de este documento + Working RGB: {name} RGB de trabajo: {name} + Working CMYK: {name} CMYK de trabajo: {name} Workspace Espacio de trabajo Workspace {i} Espacio de trabajo {i} Wrinkle Tool Herramienta Arrugar @@ -5213,6 +5217,7 @@ axis Both Ambos @msg every numbered name for {name} is taken in the document's folder todos los nombres numerados para {name} ya existen en la carpeta del documento @msg No linked files to look for No hay archivos vinculados que buscar @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas El vínculo está dentro de un símbolo, un diseño de gráfico, un motivo o una máscara de opacidad y no tiene lugar en el lienzo +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it la ilustración de un diseño de gráfico en una serie de un gráfico se vuelve a dibujar a partir del diseño: edita el diseño para cambiarla # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servicios @@ -5258,6 +5263,7 @@ axis Both Ambos @msg automation filesystem access is not granted: write authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de escritura: {path} @msg automation path rejected: outside the read root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de lectura {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} ruta de automatización rechazada: fuera de las carpetas raíz de lectura {roots}: {path} +@msg a linked JavaScript file is outside the read roots un archivo JavaScript vinculado está fuera de las carpetas raíz de lectura @msg automation path rejected: outside the write root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de escritura {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} ruta de automatización rechazada: fuera de las carpetas raíz de escritura {roots}: {path} @msg automation path rejected: path is empty ruta de automatización rechazada: la ruta está vacía @@ -6044,6 +6050,9 @@ mark-style Japanese Japonesas Softness Suavidad Ambient Light Luz ambiental Light behind the object Luz detrás del objeto + Move Light to Back Mover luz detrás + Move Light to Front Mover luz delante + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Arrastre una luz para moverla. Las teclas de flecha mueven la luz con el foco; Mayús la mueve de 15° en 15°. Shadows Sombras Distance from Object Distancia del objeto Shadow Bounds Límites de la sombra @@ -6756,6 +6765,13 @@ axis Y Axis Eje Y Graphite Pencil Lápiz de grafito Wax Crayon Cera de colores Charcoal Dust Polvo de carboncillo + +# Original procedural circuit brushes. + Tech Circuit Circuitos tecnológicos + Circuit Trace Pista de circuito + Bus Lines Líneas de bus + Terminal Nodes Nodos terminales + Notched Ribbon Cinta dentada Double Line Línea doble Wave Onda diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index ed75230a89..e32cb7fb02 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2095,6 +2095,7 @@ digits Malayalam Malajalam Show Swatch Bounds Näytä värimallin rajat Show Tile Edge Näytä ruudun reuna Size Tile to Art Sovita ruudun koko kuvitukseen + Move Tile with Art Siirrä ruutua kuvituksen mukana Tile Type: Ruudun tyyppi: Top in Front Ylin edessä V Spacing: Pystyväli: @@ -2137,6 +2138,9 @@ digits Malayalam Malajalam Use Black Point Compensation Käytä mustan pisteen kompensointia Working CMYK (don't tag) Työ-CMYK (ei tunnistetta) Working RGB (don't tag) Työ-RGB (ei tunnistetta) + Don't Color Manage This Document Älä hallitse tämän asiakirjan värejä + Working RGB: {name} Työ-RGB: {name} + Working CMYK: {name} Työ-CMYK: {name} {name} (spot) {name} (spottiväri) Add to Profiles… Lisää profiileihin… Align Stroke to Center Tasaa viiva keskelle @@ -5421,6 +5425,7 @@ imageTrace Outline Ääriviiva @msg every numbered name for {name} is taken in the document's folder kaikki kohteen {name} numeroidut nimet ovat jo käytössä asiakirjan kansiossa @msg No linked files to look for Ei etsittäviä linkitettyjä tiedostoja @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Linkki on symbolin, kaaviomallin, kuvion tai peittävyysmaskin sisällä, eikä sillä ole paikkaa piirtoalueella +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it kaavion sarjassa olevan kaaviomallin piirros piirretään uudelleen mallista: muokkaa mallia muuttaaksesi sitä @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` on vaiheviittaus {r}, jonka vain `run` ja `command_batch` ratkaisevat @msg `ids` must be an array of object ids, not {v} `ids` on oltava objektitunnusten taulukko, ei {v} @msg `colors` must be an array of colors, not {v} `colors` on oltava värien taulukko, ei {v} @@ -5440,6 +5445,9 @@ imageTrace Outline Ääriviiva @msg automation filesystem access is not granted: write authority is absent: {path} automaation tiedostojärjestelmäkäyttöä ei ole myönnetty: kirjoitusoikeus puuttuu: {path} @msg automation path rejected: outside the read root {root}: {path} automaation polku hylättiin: lukujuuren {root} ulkopuolella: {path} @msg automation path rejected: outside the read roots {roots}: {path} automaation polku hylättiin: lukujuurten {roots} ulkopuolella: {path} +@msg a linked JavaScript file is outside the read roots linkitetty JavaScript-tiedosto on lukujuurten ulkopuolella +@msg the library's file is outside the read roots kirjaston tiedosto on lukujuurten ulkopuolella +@msg {file} names more than one linked JavaScript file {file} vastaa useampaa kuin yhtä linkitettyä JavaScript-tiedostoa @msg automation path rejected: outside the write root {root}: {path} automaation polku hylättiin: kirjoitusjuuren {root} ulkopuolella: {path} @msg automation path rejected: outside the write roots {roots}: {path} automaation polku hylättiin: kirjoitusjuurten {roots} ulkopuolella: {path} @msg automation path rejected: path is empty automaation polku hylättiin: polku on tyhjä @@ -5981,6 +5989,9 @@ mark-style Japanese Japanilainen Softness Pehmeys Ambient Light Ympäristövalo Light behind the object Valo objektin takana + Move Light to Back Siirrä valo taakse + Move Light to Front Siirrä valo eteen + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Siirrä valoa vetämällä. Nuolinäppäimet siirtävät kohdistettua valoa; Vaihto siirtää 15° kerrallaan. Shadows Varjot Distance from Object Etäisyys objektista Shadow Bounds Varjon rajat @@ -6843,6 +6854,13 @@ axis Y Axis Y-akseli Graphite Pencil Grafiittikynä Wax Crayon Vahaliitu Charcoal Dust Hiilipöly + +# Original procedural circuit brushes. + Tech Circuit Tekniset virtapiirit + Circuit Trace Piirijohdin + Bus Lines Väylälinjat + Terminal Nodes Liitäntäpisteet + Notched Ribbon Lovettu nauha Double Line Kaksoisviiva Wave Aalto diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 421095c5a8..08fcc4b393 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3412,6 +3412,7 @@ digits Malayalam Malayalam Sixth Space Espace d’un sixième Size Taille Size Tile to Art Ajuster le carreau à l’illustration + Move Tile with Art Déplacer le carreau avec l’illustration Size/Leading Taille/interlignage Size: Taille : Ridges per segment: Crêtes par segment : @@ -4038,6 +4039,9 @@ digits Malayalam Malayalam Working CMYK CMJN de travail Working CMYK (don't tag) CMJN de travail (sans balise) Working RGB (don't tag) RVB de travail (sans balise) + Don't Color Manage This Document Ne pas gérer les couleurs de ce document + Working RGB: {name} RVB de travail : {name} + Working CMYK: {name} CMJN de travail : {name} Workspace Espace de travail Workspace {i} Espace de travail {i} Wrinkle Tool Outil Fronces @@ -5198,6 +5202,7 @@ axis Both Les deux @msg every numbered name for {name} is taken in the document's folder tous les noms numérotés pour {name} sont déjà pris dans le dossier du document @msg No linked files to look for Aucun fichier lié à rechercher @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Le lien se trouve dans un symbole, une conception de graphe, un motif ou un masque d’opacité et n’a pas de place sur le canevas +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it l’illustration d’une conception de graphe dans une série de graphe est redessinée à partir de la conception, qu’il faut modifier pour la changer # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Services @@ -5263,6 +5268,7 @@ axis Both Les deux @msg automation filesystem access is not granted: write authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation d’écriture : {path} @msg automation path rejected: outside the read root {root}: {path} chemin d’automatisation refusé : hors du dossier racine de lecture {root} : {path} @msg automation path rejected: outside the read roots {roots}: {path} chemin d’automatisation refusé : hors des dossiers racines de lecture {roots} : {path} +@msg a linked JavaScript file is outside the read roots un fichier JavaScript lié est hors des dossiers racines de lecture @msg automation path rejected: outside the write root {root}: {path} chemin d’automatisation refusé : hors du dossier racine d’écriture {root} : {path} @msg automation path rejected: outside the write roots {roots}: {path} chemin d’automatisation refusé : hors des dossiers racines d’écriture {roots} : {path} @msg automation path rejected: path is empty chemin d’automatisation refusé : le chemin est vide @@ -6096,6 +6102,9 @@ theme Appearance Apparence Softness Douceur Ambient Light Lumière ambiante Light behind the object Lumière derrière l'objet + Move Light to Back Placer la lumière derrière + Move Light to Front Placer la lumière devant + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Faites glisser une lumière pour la déplacer. Les touches fléchées déplacent la lumière active ; Maj par pas de 15°. Shadows Ombres Distance from Object Distance de l'objet Shadow Bounds Limites de l'ombre @@ -6806,6 +6815,13 @@ axis Y Axis Axe Y Graphite Pencil Crayon graphite Wax Crayon Craie grasse Charcoal Dust Poussière de fusain + +# Original procedural circuit brushes. + Tech Circuit Circuits techniques + Circuit Trace Piste de circuit + Bus Lines Lignes de bus + Terminal Nodes Nœuds terminaux + Notched Ribbon Ruban cranté Double Line Double ligne Wave Vague diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 6d43bfce86..e2d299ce76 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -1029,6 +1029,7 @@ Document model દસ્તાવેજ મોડેલ Document open password દસ્તાવેજ ખોલવાનો પાસવર્ડ Documents you create appear here. તમે બનાવેલા દસ્તાવેજો અહીં દેખાશે. + Don't Color Manage This Document આ દસ્તાવેજનું રંગ સંચાલન કરશો નહીં Don't Relink ફરીથી લિંક કરશો નહીં Don't Save સાચવશો નહીં Don't Show Center સેન્ટર બતાવશો નહીં @@ -1910,6 +1911,9 @@ Light Intensity લાઇટ તીવ્રતા Light Rough લાઇટ રફ Light behind the object ઑબ્જેક્ટની પાછળ લાઇટ + Move Light to Back લાઇટ પાછળ ખસેડો + Move Light to Front લાઇટ આગળ ખસેડો + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. લાઇટ ખસેડવા માટે તેને ખેંચો. ઍરો કી ફોકસ થયેલી લાઇટ ખસેડે છે; Shift સાથે 15° ના પગલાં. Lighten હળવું કરો Lighting લાઇટિંગ Lights લાઇટ્સ @@ -3536,6 +3540,7 @@ Sixth Space છઠ્ઠી જગ્યા Size કદ Size Tile to Art ટાઇલનું કદ આર્ટ પ્રમાણે કરો + Move Tile with Art ટાઇલને આર્ટ સાથે ખસેડો Size/Leading કદ/લીડિંગ Size: કદ: Size: {size} Tracking: {tracking} કદ: {size} ટ્રેકિંગ: {tracking} @@ -4177,6 +4182,8 @@ Word Spacing: શબ્દ અંતર: Words Longer Than: આનાથી લાંબા શબ્દો: Working CMYK કાર્યકારી CMYK + Working CMYK: {name} કાર્યકારી CMYK: {name} + Working RGB: {name} કાર્યકારી RGB: {name} Workspace વર્કસ્પેસ Wrinkle કરચલી Wrinkle Tool કરચલી ટૂલ @@ -4503,6 +4510,13 @@ antiAlias Sharp શાર્પ Graphite Pencil ગ્રેફાઇટ પેન્સિલ Wax Crayon વેક્સ ક્રેયોન Charcoal Dust ચારકોલનો ભૂકો + +# Original procedural circuit brushes. + Tech Circuit ટેક સર્કિટ + Circuit Trace સર્કિટ પથ + Bus Lines બસ રેખાઓ + Terminal Nodes ટર્મિનલ નોડ્સ + Notched Ribbon ખાંચાવાળી રિબન Double Line બેવડી રેખા Wave તરંગ diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 87553a4c84..9e7c1fa6fa 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -444,6 +444,13 @@ antiAlias Sharp शार्प Graphite Pencil ग्रेफाइट पेंसिल Wax Crayon मोम क्रेयॉन Charcoal Dust चारकोल धूल + +# Original procedural circuit brushes. + Tech Circuit टेक सर्किट + Circuit Trace सर्किट पथ + Bus Lines बस रेखाएँ + Terminal Nodes टर्मिनल नोड + Notched Ribbon नोकदार रिबन Double Line दोहरी लाइन Wave लहर @@ -2580,6 +2587,9 @@ antiAlias Sharp शार्प Ligatures are off while the text is tracked. टेक्स्ट पर ट्रैकिंग लागू होने पर लिगेचर ऑफ़ रहते हैं। Light लाइट Light behind the object ऑब्जेक्ट के पीछे प्रकाश + Move Light to Back प्रकाश को पीछे ले जाएँ + Move Light to Front प्रकाश को आगे ले जाएँ + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. प्रकाश को हिलाने के लिए उसे खींचें। तीर कुंजियाँ फ़ोकस वाले प्रकाश को हिलाती हैं; Shift के साथ 15° के चरण। Shadows छायाएँ Distance from Object ऑब्जेक्ट से दूरी Shadow Bounds छाया सीमाएँ @@ -4689,6 +4699,7 @@ antiAlias Sharp शार्प Sixth Space छठे हिस्से जितना स्पेस Size आकार Size Tile to Art आकार टाइल कला के लिए + Move Tile with Art टाइल को कला के साथ ले जाएँ Size: साइज़: Size: {size} Tracking: {tracking} साइज़: {size} ट्रैकिंग: {tracking} Size/Leading साइज़/लीडिंग @@ -5566,6 +5577,9 @@ antiAlias Sharp शार्प Working CMYK वर्किंग CMYK Working CMYK (don't tag) कार्य CMYK (टैग नहीं) Working RGB (don't tag) कार्य RGB (टैग नहीं) + Don't Color Manage This Document इस दस्तावेज़ का रंग प्रबंधन न करें + Working RGB: {name} कार्य RGB: {name} + Working CMYK: {name} कार्य CMYK: {name} Working RGB वर्किंग RGB Workspace वर्कस्पेस Workspace {i} वर्कस्पेस {i} @@ -6457,6 +6471,9 @@ antiAlias Sharp शार्प @msg automation path rejected: cannot resolve {path}: {e} स्वचालन पाथ खारिज कर दिया: {path} को हल नहीं कर सकता: {e} @msg automation path rejected: outside the read root {root}: {path} स्वचालन पाथ खारिज: पढ़ने की जड़ के बाहर {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} स्वचालन पाथ खारिज: पढ़ने की जड़ों के बाहर {roots}: {path} +@msg a linked JavaScript file is outside the read roots एक लिंक की गई जावास्क्रिप्ट फ़ाइल पढ़ने की जड़ों के बाहर है +@msg the library's file is outside the read roots लाइब्रेरी की फ़ाइल पढ़ने की जड़ों के बाहर है +@msg {file} names more than one linked JavaScript file {file} एक से अधिक लिंक की गई जावास्क्रिप्ट फ़ाइलों से मेल खाता है @msg automation path rejected: outside the write root {root}: {path} स्वचालन पाथ खारिज: लिखने की जड़ के बाहर {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} स्वचालन पाथ खारिज: लिखने की जड़ों के बाहर {roots}: {path} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} स्वचालन पाथ खारिज कर दिया: एक फ़ोल्डर के नीचे माता-पिता traversal जो मौजूद नहीं है: {path} @@ -7369,6 +7386,7 @@ antiAlias Sharp शार्प @msg No line breaks to remove हटाने के लिए कोई लाइन ब्रेक नहीं @msg No linked files to look for देखने के लिए कोई लिंक्ड फ़ाइल नहीं @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas लिंक किसी सिंबल, ग्राफ़ डिज़ाइन, पैटर्न या ओपेसिटी मास्क के अंदर है और कैनवास पर इसकी कोई जगह नहीं है +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it ग्राफ़ श्रृंखला में ग्राफ़ डिज़ाइन की कला डिज़ाइन से फिर से बनाई जाती है: इसे बदलने के लिए डिज़ाइन संपादित करें @msg no Live Paint edge near that point उस बिंदु के पास कोई लाइव पेंट एज नहीं @msg no Live Paint face at that point उस बिंदु पर कोई लाइव पेंट फेस नहीं @msg no metafile format कोई मेटाफ़ाइल फ़ॉर्मैट diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ef3b1b4702..f12923ee31 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4069,6 +4069,7 @@ mark-style Japanese Japán Sixth Space Hatodkvirt-szóköz Size Méret Size Tile to Art Csempe méretezése a grafikához + Move Tile with Art Csempe mozgatása a grafikával Size/Leading Méret/sorköz Size: Méret: Size: {size} Tracking: {tracking} Méret: {size} Betűköz: {tracking} @@ -4788,6 +4789,9 @@ mark-style Japanese Japán Working CMYK Munka-CMYK Working CMYK (don't tag) Munka-CMYK (címke nélkül) Working RGB (don't tag) Munka-RGB (címke nélkül) + Don't Color Manage This Document Ne legyen színkezelés ebben a dokumentumban + Working RGB: {name} Munka-RGB: {name} + Working CMYK: {name} Munka-CMYK: {name} Working RGB Munka-RGB Workspace Munkaterület Workspace {i} Munkaterület {i} @@ -5044,6 +5048,7 @@ imageTrace Outline Körvonal @msg automation path rejected: cannot resolve {path}: {e} az automatizálás útvonala elutasítva: nem oldható fel: {path}: {e} @msg automation path rejected: outside the read root {root}: {path} az automatizálás útvonala elutasítva: kívül esik az olvasási gyökéren ({root}): {path} @msg automation path rejected: outside the read roots {roots}: {path} az automatizálás útvonala elutasítva: kívül esik az olvasási gyökereken ({roots}): {path} +@msg a linked JavaScript file is outside the read roots egy csatolt JavaScript-fájl kívül esik az olvasási gyökereken @msg automation path rejected: outside the write root {root}: {path} az automatizálás útvonala elutasítva: kívül esik az írási gyökéren ({root}): {path} @msg automation path rejected: outside the write roots {roots}: {path} az automatizálás útvonala elutasítva: kívül esik az írási gyökereken ({roots}): {path} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} az automatizálás útvonala elutasítva: szülőmappára lépés egy nem létező mappa alatt: {path} @@ -5252,6 +5257,7 @@ imageTrace Outline Körvonal @msg every numbered name for {name} is taken in the document's folder a dokumentum mappájában minden sorszámozott név foglalt: {name} @msg No linked files to look for Nincs keresendő csatolt fájl @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas A csatolás egy szimbólumban, diagrammintában, mintázatban vagy átlátszósági maszkban van, és nincs helye a vásznon +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it egy diagram adatsorában a diagramminta grafikája a mintából rajzolódik újra: a módosításához szerkeszd a mintát @msg every search thread stopped on an internal error (please report this bug) minden keresési szál belső hibával leállt (kérjük, jelentsd ezt a hibát) @msg Expand Appearance: the selection has a basic appearance Megjelenés kibontása: a kijelölésnek alapmegjelenése van @msg Expand: no brush strokes selected Kibontás: nincs kijelölt ecsetvonás @@ -6083,6 +6089,9 @@ imageTrace Outline Körvonal Softness Lágyság Ambient Light Környezeti fény Light behind the object Fény az objektum mögött + Move Light to Back Fény hátra + Move Light to Front Fény előre + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Húzzon egy fényt az áthelyezéséhez. A nyílbillentyűk a fókuszban lévő fényt mozgatják; Shifttel 15°-onként. Shadows Árnyékok Distance from Object Távolság az objektumtól Shadow Bounds Árnyék határai @@ -6794,6 +6803,13 @@ axis Y Axis Y tengely Graphite Pencil Grafitceruza Wax Crayon Zsírkréta Charcoal Dust Szénpor + +# Original procedural circuit brushes. + Tech Circuit Műszaki áramkörök + Circuit Trace Vezetősáv + Bus Lines Buszvonalak + Terminal Nodes Csatlakozási pontok + Notched Ribbon Csipkézett szalag Double Line Dupla vonal Wave Hullám diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index dc1930ea0a..7fc6760e3d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1934,6 +1934,7 @@ digits Malayalam Malayalam Show Swatch Bounds Tampilkan batas swatch Show Tile Edge Tampilkan tepi ubin Size Tile to Art Sesuaikan ukuran ubin dengan karya seni + Move Tile with Art Pindahkan ubin bersama karya seni Tile Type: Jenis ubin: Top in Front Atas di depan V Spacing: Jarak V: @@ -1976,6 +1977,9 @@ digits Malayalam Malayalam Use Black Point Compensation Gunakan kompensasi titik hitam Working CMYK (don't tag) CMYK kerja (tanpa tag) Working RGB (don't tag) RGB kerja (tanpa tag) + Don't Color Manage This Document Jangan kelola warna dokumen ini + Working RGB: {name} RGB kerja: {name} + Working CMYK: {name} CMYK kerja: {name} {name} (spot) {name} (spot) Add to Profiles… Tambahkan ke profil… Align Stroke to Center Ratakan stroke ke tengah @@ -5444,6 +5448,9 @@ mark-style Japanese Jepang Softness Kelembutan Ambient Light Cahaya ambient Light behind the object Cahaya di belakang objek + Move Light to Back Pindahkan Cahaya ke Belakang + Move Light to Front Pindahkan Cahaya ke Depan + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Seret cahaya untuk memindahkannya. Tombol panah memindahkan cahaya yang difokuskan; Shift memindahkannya 15°. Shadows Bayangan Distance from Object Jarak dari objek Shadow Bounds Batas bayangan @@ -6270,6 +6277,13 @@ axis Y Axis Sumbu Y Graphite Pencil Pensil grafit Wax Crayon Krayon lilin Charcoal Dust Debu arang + +# Original procedural circuit brushes. + Tech Circuit Sirkuit teknologi + Circuit Trace Jalur sirkuit + Bus Lines Jalur bus + Terminal Nodes Simpul terminal + Notched Ribbon Pita bertakik Double Line Garis ganda Wave Gelombang diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 88638286d3..0d33fe8b64 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3465,6 +3465,7 @@ digits Malayalam Malayalam Sixth Space Spazio di un sesto Size Dimensione Size Tile to Art Adatta tassello alla grafica + Move Tile with Art Sposta tassello con la grafica Size/Leading Dimensione/interlinea Size: Dimensione: Ridges per segment: Creste per segmento: @@ -4094,6 +4095,9 @@ digits Malayalam Malayalam Working CMYK CMYK di lavoro Working CMYK (don't tag) CMYK di lavoro (senza tag) Working RGB (don't tag) RGB di lavoro (senza tag) + Don't Color Manage This Document Non gestire il colore di questo documento + Working RGB: {name} RGB di lavoro: {name} + Working CMYK: {name} CMYK di lavoro: {name} Workspace Area di lavoro Workspace {i} Area di lavoro {i} Wrinkle Tool Strumento Grinza @@ -5263,6 +5267,7 @@ axis Both Entrambi @msg every numbered name for {name} is taken in the document's folder tutti i nomi numerati per {name} sono già usati nella cartella del documento @msg No linked files to look for Nessun file collegato da cercare @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Il collegamento si trova in un simbolo, un design di grafico, un motivo o una maschera di opacità e non ha una posizione nell’area di disegno +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it la grafica di un design di grafico in una serie del grafico viene ridisegnata dal design: modifica il design per cambiarla # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servizi @@ -5308,6 +5313,7 @@ axis Both Entrambi @msg automation filesystem access is not granted: write authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di scrittura: {path} @msg automation path rejected: outside the read root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di lettura {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} percorso di automazione rifiutato: fuori dalle cartelle radice di lettura {roots}: {path} +@msg a linked JavaScript file is outside the read roots un file JavaScript collegato è fuori dalle cartelle radice di lettura @msg automation path rejected: outside the write root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di scrittura {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} percorso di automazione rifiutato: fuori dalle cartelle radice di scrittura {roots}: {path} @msg automation path rejected: path is empty percorso di automazione rifiutato: il percorso è vuoto @@ -5717,6 +5723,9 @@ lighting Light Luce Softness Morbidezza Ambient Light Luce ambientale Light behind the object Luce dietro l'oggetto + Move Light to Back Sposta luce dietro + Move Light to Front Sposta luce davanti + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Trascina una luce per spostarla. I tasti freccia spostano la luce attiva; Maiusc a passi di 15°. Shadows Ombre Distance from Object Distanza dall'oggetto Shadow Bounds Limiti dell'ombra @@ -6437,6 +6446,13 @@ axis Y Axis Asse Y Graphite Pencil Matita di grafite Wax Crayon Pastello a cera Charcoal Dust Polvere di carboncino + +# Original procedural circuit brushes. + Tech Circuit Circuiti tecnologici + Circuit Trace Pista del circuito + Bus Lines Linee del bus + Terminal Nodes Nodi terminali + Notched Ribbon Nastro dentellato Double Line Doppia linea Wave Onda diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index cba830841b..0f1231d5d4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3281,6 +3281,7 @@ mark-style Japanese 日本式 Single-line Composer 1 行ずつ組む Toggle Line Composer 行の組版方式を切り替え Size Tile to Art タイルをアートワークのサイズに合わせる + Move Tile with Art タイルをアートワークと一緒に移動 Size/Leading サイズ/行送り Size: サイズ: Ridges per segment: セグメントごとの山数: @@ -4122,6 +4123,9 @@ file-filter Templates テンプレート Windows Windows Working CMYK (don't tag) 作業用 CMYK (タグなし) Working RGB (don't tag) 作業用 RGB (タグなし) + Don't Color Manage This Document このドキュメントにカラーマネジメントを適用しない + Working RGB: {name} 作業用 RGB: {name} + Working CMYK: {name} 作業用 CMYK: {name} Workspace {i} ワークスペース {i} Yellow イエロー [Normal Character Style] [標準文字スタイル] @@ -5330,6 +5334,7 @@ swatch Bright Violet ビビッドバイオレット @msg every numbered name for {name} is taken in the document's folder ドキュメントのフォルダーでは、{name} の番号付きの名前がすべて使われています @msg No linked files to look for 検索するリンクファイルがありません @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas このリンクはシンボル、グラフデザイン、パターン、または不透明マスクの中にあり、カンバス上に位置がありません +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it グラフの系列にあるグラフデザインのアートはデザインから描き直されます: 変更するにはデザインを編集してください @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです @msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません) @msg `colors` must be an array of colors, not {v} `colors` は色の配列にしてください ({v} は使えません) @@ -5349,6 +5354,7 @@ swatch Bright Violet ビビッドバイオレット @msg automation filesystem access is not granted: write authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 書き込み権限がありません: {path} @msg automation path rejected: outside the read root {root}: {path} 自動化のパスを拒否しました: 読み込みルート {root} の外です: {path} @msg automation path rejected: outside the read roots {roots}: {path} 自動化のパスを拒否しました: 読み込みルート {roots} のいずれの中でもありません: {path} +@msg a linked JavaScript file is outside the read roots リンクされた JavaScript ファイルが読み込みルートの外にあります @msg automation path rejected: outside the write root {root}: {path} 自動化のパスを拒否しました: 書き込みルート {root} の外です: {path} @msg automation path rejected: outside the write roots {roots}: {path} 自動化のパスを拒否しました: 書き込みルート {roots} のいずれの中でもありません: {path} @msg automation path rejected: path is empty 自動化のパスを拒否しました: パスが空です @@ -5760,6 +5766,9 @@ lighting Light ライト Softness 柔らかさ Ambient Light 環境光 Light behind the object オブジェクトの背後のライト + Move Light to Back ライトを背面へ + Move Light to Front ライトを前面へ + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. ライトをドラッグして移動します。矢印キーでフォーカスのあるライトを移動し、Shift キーで 15° ずつ移動します。 Shadows シャドウ Distance from Object オブジェクトからの距離 Shadow Bounds シャドウの範囲 @@ -8309,6 +8318,13 @@ axis Y Axis Y 軸 Graphite Pencil 黒鉛鉛筆 Wax Crayon ワックスクレヨン Charcoal Dust 木炭の粉 + +# Original procedural circuit brushes. + Tech Circuit 電子回路 + Circuit Trace 回路配線 + Bus Lines バス配線 + Terminal Nodes 端子ノード + Notched Ribbon ノッチリボン Double Line 二重線 Wave 波 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index f52a225d84..5f49f73675 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1863,6 +1863,7 @@ digits Malayalam მალაიალამური Show Swatch Bounds ფერის ნიმუშის საზღვრების ჩვენება Show Tile Edge ფილის კიდის ჩვენება Size Tile to Art ფილის ზომის ნახატზე მორგება + Move Tile with Art ფილის ნახატთან ერთად გადატანა Tile Type: ფილის ტიპი: Top in Front ზედა წინ V Spacing: ვერტ. დაშორება: @@ -1905,6 +1906,9 @@ digits Malayalam მალაიალამური Use Black Point Compensation შავი წერტილის კომპენსაციის გამოყენება Working CMYK (don't tag) სამუშაო CMYK (ტეგის გარეშე) Working RGB (don't tag) სამუშაო RGB (ტეგის გარეშე) + Don't Color Manage This Document ამ დოკუმენტის ფერების მართვის გარეშე + Working RGB: {name} სამუშაო RGB: {name} + Working CMYK: {name} სამუშაო CMYK: {name} {name} (spot) {name} (სპოტ) Add to Profiles… პროფილებში დამატება… Align Stroke to Center კონტურის ცენტრზე სწორება @@ -4864,6 +4868,9 @@ font-technology Variable ცვლადი @msg automation filesystem access is not granted: write authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: ჩაწერის უფლება არ არის: {path} @msg automation path rejected: outside the read root {root}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირის {root} გარეთაა: {path} @msg automation path rejected: outside the read roots {roots}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირების {roots} გარეთაა: {path} +@msg a linked JavaScript file is outside the read roots დაკავშირებული JavaScript ფაილი წაკითხვის ძირების გარეთაა +@msg the library's file is outside the read roots ბიბლიოთეკის ფაილი წაკითხვის ძირების გარეთაა +@msg {file} names more than one linked JavaScript file {file} ერთზე მეტ დაკავშირებულ JavaScript ფაილს შეესაბამება @msg automation path rejected: outside the write root {root}: {path} ავტომატიზაციის გზა უარყოფილია: ჩაწერის ძირის {root} გარეთაა: {path} @msg automation path rejected: outside the write roots {roots}: {path} ავტომატიზაციის გზა უარყოფილია: ჩაწერის ძირების {roots} გარეთაა: {path} @msg automation path rejected: path is empty ავტომატიზაციის გზა უარყოფილია: გზა ცარიელია @@ -5225,6 +5232,7 @@ font-technology Variable ცვლადი @msg every numbered name for {name} is taken in the document's folder დოკუმენტის საქაღალდეში {name}-ის ყველა დანომრილი სახელი დაკავებულია @msg No linked files to look for მოსაძებნი დაკავშირებული ფაილები არ არის @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas ბმული სიმბოლოში, დიაგრამის დიზაინში, პატერნში ან გაუმჭვირვალობის ნიღაბშია და ტილოზე ადგილი არ აქვს +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it დიაგრამის სერიაში დიაგრამის დიზაინის გრაფიკა დიზაინიდან თავიდან იხატება: მის შესაცვლელად დაარედაქტირეთ დიზაინი Ruler Origin სახაზავების სათავე Reset Ruler Origin სახაზავების სათავის ჩამოყრა Artistic მხატვრული @@ -5653,6 +5661,9 @@ imageTrace Outline კონტური In the File ფაილში Keys კლავიშები Light behind the object სინათლე ობიექტის უკან + Move Light to Back სინათლის უკან გადატანა + Move Light to Front სინათლის წინ გადატანა + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. გადაათრიეთ სინათლე მის გადასაადგილებლად. ისრიანი ღილაკები ფოკუსირებულ სინათლეს ამოძრავებს; Shift-ით 15°-იანი ბიჯით. Shadows ჩრდილები Distance from Object მანძილი ობიექტიდან Shadow Bounds ჩრდილის საზღვრები @@ -6764,6 +6775,13 @@ axis Y Axis Y ღერძი Graphite Pencil გრაფიტის ფანქარი Wax Crayon ცვილის ფანქარი Charcoal Dust ნახშირის მტვერი + +# Original procedural circuit brushes. + Tech Circuit ტექნიკური სქემები + Circuit Trace სქემის გამტარი + Bus Lines სალტის ხაზები + Terminal Nodes ტერმინალური კვანძები + Notched Ribbon დაკბილული ლენტი Double Line ორმაგი ხაზი Wave ტალღა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 74a6bce25e..2ff5d2b148 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3270,6 +3270,7 @@ mark-style Japanese 일본식 Single-line Composer 한 줄 조판 Toggle Line Composer 줄 조판 방식 전환 Size Tile to Art 타일을 아트웍 크기에 맞춤 + Move Tile with Art 아트웍과 함께 타일 이동 Size/Leading 크기/행간 Size: 크기: Ridges per segment: 세그먼트당 융기 수: @@ -4110,6 +4111,9 @@ file-filter Templates 템플릿 Windows Windows Working CMYK (don't tag) 작업용 CMYK (태그 안 함) Working RGB (don't tag) 작업용 RGB (태그 안 함) + Don't Color Manage This Document 이 문서에 색상 관리 안 함 + Working RGB: {name} 작업용 RGB: {name} + Working CMYK: {name} 작업용 CMYK: {name} Workspace {i} 작업 영역 {i} Yellow 노랑 [Normal Character Style] [기본 문자 스타일] @@ -5257,6 +5261,7 @@ theme Appearance 외관 @msg every numbered name for {name} is taken in the document's folder 문서 폴더에서 {name}을(를) 위한 번호 이름이 모두 사용 중입니다 @msg No linked files to look for 찾을 링크된 파일이 없습니다 @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 링크가 심볼, 그래프 디자인, 패턴 또는 불투명 마스크 안에 있어 캔버스에 위치가 없습니다 +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it 그래프 계열에 있는 그래프 디자인의 아트는 디자인에서 다시 그려집니다: 변경하려면 디자인을 편집하세요 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id`는 단계 참조 {r}이며, `run`과 `command_batch`만 이를 해석합니다 @msg `ids` must be an array of object ids, not {v} `ids`는 {v}이(가) 아니라 개체 id 배열이어야 합니다 @msg `colors` must be an array of colors, not {v} `colors`는 {v}이(가) 아니라 색상 배열이어야 합니다 @@ -5276,6 +5281,7 @@ theme Appearance 외관 @msg automation filesystem access is not granted: write authority is absent: {path} 자동화 파일 시스템 접근이 허용되지 않았습니다: 쓰기 권한이 없습니다: {path} @msg automation path rejected: outside the read root {root}: {path} 자동화 경로가 거부되었습니다: 읽기 루트 {root} 밖입니다: {path} @msg automation path rejected: outside the read roots {roots}: {path} 자동화 경로가 거부되었습니다: 읽기 루트 {roots} 밖입니다: {path} +@msg a linked JavaScript file is outside the read roots 연결된 JavaScript 파일이 읽기 루트 밖에 있습니다 @msg automation path rejected: outside the write root {root}: {path} 자동화 경로가 거부되었습니다: 쓰기 루트 {root} 밖입니다: {path} @msg automation path rejected: outside the write roots {roots}: {path} 자동화 경로가 거부되었습니다: 쓰기 루트 {roots} 밖입니다: {path} @msg automation path rejected: path is empty 자동화 경로가 거부되었습니다: 경로가 비어 있습니다 @@ -5678,6 +5684,9 @@ lighting Light 광원 Softness 부드러움 Ambient Light 주변광 Light behind the object 개체 뒤의 조명 + Move Light to Back 조명을 뒤로 이동 + Move Light to Front 조명을 앞으로 이동 + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. 조명을 드래그하여 이동합니다. 화살표 키로 포커스된 조명을 이동하고, Shift 키를 누르면 15°씩 이동합니다. Shadows 그림자 Distance from Object 개체로부터의 거리 Shadow Bounds 그림자 범위 @@ -6398,6 +6407,13 @@ axis Y Axis Y축 Graphite Pencil 흑연 연필 Wax Crayon 왁스 크레용 Charcoal Dust 목탄 가루 + +# Original procedural circuit brushes. + Tech Circuit 전자 회로 + Circuit Trace 회로 배선 + Bus Lines 버스 배선 + Terminal Nodes 단자 노드 + Notched Ribbon 노치 리본 Double Line 이중선 Wave 물결 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a10e70d46c..b05beb7627 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2452,6 +2452,7 @@ digits Malayalam Malayalam Show Swatch Bounds Staalgrenzen weergeven Show Tile Edge Tegelrand weergeven Size Tile to Art Tegel aan artwork aanpassen + Move Tile with Art Tegel met artwork verplaatsen Tile Type: Tegeltype: Top in Front Bovenkant voorop V Spacing: V-spatiëring: @@ -2494,6 +2495,9 @@ digits Malayalam Malayalam Use Black Point Compensation Zwartpuntcompensatie gebruiken Working CMYK (don't tag) Werk-CMYK (niet taggen) Working RGB (don't tag) Werk-RGB (niet taggen) + Don't Color Manage This Document Geen kleurbeheer voor dit document + Working RGB: {name} Werk-RGB: {name} + Working CMYK: {name} Werk-CMYK: {name} {name} (spot) {name} (spotkleur) Add to Profiles… Toevoegen aan profielen… Align Stroke to Center Lijn in het midden uitlijnen @@ -7847,6 +7851,9 @@ mark-style Japanese Japans Softness Zachtheid Ambient Light Omgevingslicht Light behind the object Licht achter het object + Move Light to Back Licht naar achteren + Move Light to Front Licht naar voren + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Sleep een licht om het te verplaatsen. De pijltoetsen verplaatsen het licht met de focus; Shift verplaatst het per 15°. Shadows Schaduwen Distance from Object Afstand tot object Shadow Bounds Schaduwgrenzen @@ -8715,6 +8722,13 @@ axis Y Axis Y-as Graphite Pencil Grafietpotlood Wax Crayon Waskrijt Charcoal Dust Houtskoolstof + +# Original procedural circuit brushes. + Tech Circuit Technische schakelingen + Circuit Trace Printspoor + Bus Lines Buslijnen + Terminal Nodes Aansluitpunten + Notched Ribbon Gekerfd lint Double Line Dubbele lijn Wave Golf diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3a3ac2040d..0f2922af89 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2052,6 +2052,7 @@ digits Malayalam Malajalam Show Swatch Bounds Pokaż granice próbki Show Tile Edge Pokaż krawędź kafelka Size Tile to Art Dopasuj kafelek do kompozycji + Move Tile with Art Przesuwaj kafelek z kompozycją Tile Type: Typ kafelka: Top in Front Górny na wierzchu V Spacing: Odstęp pion.: @@ -2094,6 +2095,9 @@ digits Malayalam Malajalam Use Black Point Compensation Użyj kompensacji punktu czerni Working CMYK (don't tag) Roboczy CMYK (bez znacznika) Working RGB (don't tag) Roboczy RGB (bez znacznika) + Don't Color Manage This Document Nie zarządzaj kolorem tego dokumentu + Working RGB: {name} Roboczy RGB: {name} + Working CMYK: {name} Roboczy CMYK: {name} {name} (spot) {name} (dodatkowy) Add to Profiles… Dodaj do profili… Align Stroke to Center Wyrównaj obrys do środka @@ -5347,6 +5351,7 @@ imageTrace Outline Kontur @msg every numbered name for {name} is taken in the document's folder wszystkie numerowane nazwy dla {name} są zajęte w folderze dokumentu @msg No linked files to look for Brak połączonych plików do wyszukania @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Łącze znajduje się w symbolu, wzorze wykresu, wzorku lub masce krycia i nie ma miejsca na obszarze roboczym +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it grafika wzoru wykresu w serii danych wykresu jest rysowana od nowa na podstawie wzoru: aby ją zmienić, edytuj wzór @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` to odwołanie do kroku {r}, które rozwiązują tylko `run` i `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musi być tablicą identyfikatorów obiektów, nie {v} @msg `colors` must be an array of colors, not {v} `colors` musi być tablicą kolorów, nie {v} @@ -5366,6 +5371,7 @@ imageTrace Outline Kontur @msg automation filesystem access is not granted: write authority is absent: {path} nie przyznano dostępu automatyzacji do systemu plików: brak uprawnień do zapisu: {path} @msg automation path rejected: outside the read root {root}: {path} odrzucono ścieżkę automatyzacji: poza katalogiem głównym odczytu {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} odrzucono ścieżkę automatyzacji: poza katalogami głównymi odczytu {roots}: {path} +@msg a linked JavaScript file is outside the read roots połączony plik JavaScript jest poza katalogami głównymi odczytu @msg automation path rejected: outside the write root {root}: {path} odrzucono ścieżkę automatyzacji: poza katalogiem głównym zapisu {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} odrzucono ścieżkę automatyzacji: poza katalogami głównymi zapisu {roots}: {path} @msg automation path rejected: path is empty odrzucono ścieżkę automatyzacji: ścieżka jest pusta @@ -5940,6 +5946,9 @@ mark-style Japanese Japońskie Softness Miękkość Ambient Light Światło otoczenia Light behind the object Światło za obiektem + Move Light to Back Przenieś światło do tyłu + Move Light to Front Przenieś światło do przodu + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Przeciągnij światło, aby je przesunąć. Klawisze strzałek przesuwają aktywne światło; z Shift o 15°. Shadows Cienie Distance from Object Odległość od obiektu Shadow Bounds Granice cienia @@ -6666,6 +6675,13 @@ axis Y Axis Oś Y Graphite Pencil Ołówek grafitowy Wax Crayon Kredka woskowa Charcoal Dust Pył węglowy + +# Original procedural circuit brushes. + Tech Circuit Obwody techniczne + Circuit Trace Ścieżka obwodu + Bus Lines Linie magistrali + Terminal Nodes Węzły końcowe + Notched Ribbon Wstęga z nacięciami Double Line Podwójna linia Wave Fala diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a39a1f5546..1f129dd5e6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2892,6 +2892,7 @@ digits Malayalam Malaiala Sixth Space Espaço de Sexto Size Tamanho Size Tile to Art Dimensionar Bloco para a Arte + Move Tile with Art Mover Bloco com a Arte Size/Leading Tamanho/Entrelinhamento Size: Tamanho: Size: {size} Tracking: {tracking} Tamanho: {size} Rastreamento: {tracking} @@ -4668,6 +4669,7 @@ axis Both Ambos @msg automation filesystem access is not granted: write authority is absent: {path} o acesso da automação ao sistema de arquivos não foi concedido: falta autorização de gravação: {path} @msg automation path rejected: outside the read root {root}: {path} caminho da automação rejeitado: fora da raiz de leitura {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} caminho da automação rejeitado: fora das raízes de leitura {roots}: {path} +@msg a linked JavaScript file is outside the read roots um arquivo JavaScript vinculado está fora das raízes de leitura @msg automation path rejected: outside the write root {root}: {path} caminho da automação rejeitado: fora da raiz de gravação {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} caminho da automação rejeitado: fora das raízes de gravação {roots}: {path} @msg automation path rejected: path is empty caminho da automação rejeitado: o caminho está vazio @@ -4801,6 +4803,9 @@ axis Both Ambos Softness Suavidade Ambient Light Luz ambiente Light behind the object Luz atrás do objeto + Move Light to Back Mover luz para trás + Move Light to Front Mover luz para a frente + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Arraste uma luz para movê-la. As teclas de seta movem a luz em foco; Shift a move de 15° em 15°. Shadows Sombras Distance from Object Distância do objeto Shadow Bounds Limites da sombra @@ -5317,6 +5322,9 @@ axis Both Ambos Windows Windows Working CMYK (don't tag) CMYK de Trabalho (não marcar) Working RGB (don't tag) RGB de Trabalho (não marcar) + Don't Color Manage This Document Não gerenciar as cores deste documento + Working RGB: {name} RGB de Trabalho: {name} + Working CMYK: {name} CMYK de Trabalho: {name} Workspace {i} Espaço de Trabalho {i} Yellow Amarelo [Normal Character Style] [Estilo de Caractere Normal] @@ -5448,6 +5456,7 @@ axis Both Ambos @msg every numbered name for {name} is taken in the document's folder todos os nomes numerados para {name} já existem na pasta do documento @msg No linked files to look for Nenhum arquivo vinculado para procurar @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas O link está dentro de um símbolo, projeto de gráfico, padrão ou máscara de opacidade e não tem lugar na tela +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it a arte de um projeto de gráfico em uma série do gráfico é desenhada de novo a partir do projeto: edite o projeto para alterá-la De-Interlace Desentrelaçar Eliminate Eliminar Create New Fields by Criar Novos Campos por @@ -6726,6 +6735,13 @@ axis Y Axis Eixo Y Graphite Pencil Lápis de Grafite Wax Crayon Giz de Cera Charcoal Dust Pó de Carvão + +# Original procedural circuit brushes. + Tech Circuit Circuitos tecnológicos + Circuit Trace Trilha de circuito + Bus Lines Linhas de barramento + Terminal Nodes Nós terminais + Notched Ribbon Fita Entalhada Double Line Linha Dupla Wave Onda diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9cd4b3542e..b267f7cb09 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3452,6 +3452,7 @@ digits Malayalam Малаялам Sixth Space Шестая шпация Size Размер Size Tile to Art Подогнать плитку по рисунку + Move Tile with Art Перемещать плитку вместе с рисунком Size/Leading Размер/интерлиньяж Size: Размер: Ridges per segment: Гребней на сегмент: @@ -4079,6 +4080,9 @@ digits Malayalam Малаялам Working CMYK Рабочий CMYK Working CMYK (don't tag) Рабочий CMYK (без пометки) Working RGB (don't tag) Рабочий RGB (без пометки) + Don't Color Manage This Document Не управлять цветом этого документа + Working RGB: {name} Рабочий RGB: {name} + Working CMYK: {name} Рабочий CMYK: {name} Workspace Рабочая среда Workspace {i} Рабочая среда {i} Wrinkle Tool Инструмент «Морщины» @@ -5250,6 +5254,7 @@ axis Both Оба @msg every numbered name for {name} is taken in the document's folder все нумерованные имена для {name} уже заняты в папке документа @msg No linked files to look for Нет связанных файлов для поиска @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Связь находится внутри символа, дизайна диаграммы, узора или непрозрачной маски и не имеет места на холсте +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it графика дизайна диаграммы в ряду данных диаграммы перерисовывается по дизайну: чтобы изменить её, отредактируйте дизайн # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Службы @@ -5297,6 +5302,7 @@ axis Both Оба @msg automation filesystem access is not granted: write authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на запись: {path} @msg automation path rejected: outside the read root {root}: {path} путь автоматизации отклонён: вне корневой папки для чтения {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} путь автоматизации отклонён: вне корневых папок для чтения {roots}: {path} +@msg a linked JavaScript file is outside the read roots связанный файл JavaScript находится вне корневых папок для чтения @msg automation path rejected: outside the write root {root}: {path} путь автоматизации отклонён: вне корневой папки для записи {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} путь автоматизации отклонён: вне корневых папок для записи {roots}: {path} @msg automation path rejected: path is empty путь автоматизации отклонён: путь пуст @@ -5704,6 +5710,9 @@ lighting Light Свет Softness Мягкость Ambient Light Окружающий свет Light behind the object Свет позади объекта + Move Light to Back Переместить свет назад + Move Light to Front Переместить свет вперёд + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Перетащите источник света, чтобы переместить его. Клавиши со стрелками перемещают выбранный источник; с Shift — шагами по 15°. Shadows Тени Distance from Object Расстояние от объекта Shadow Bounds Границы тени @@ -6424,6 +6433,13 @@ axis Y Axis Ось Y Graphite Pencil Графитный карандаш Wax Crayon Восковой мелок Charcoal Dust Угольная пыль + +# Original procedural circuit brushes. + Tech Circuit Технические схемы + Circuit Trace Дорожка схемы + Bus Lines Линии шины + Terminal Nodes Контактные узлы + Notched Ribbon Лента с насечками Double Line Двойная линия Wave Волна diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e26bddc2ad..b6ca9a9716 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3342,6 +3342,7 @@ digits Malayalam Malayalam Sixth Space Sjättedelsblanksteg Size Storlek Size Tile to Art Anpassa rutans storlek efter grafiken + Move Tile with Art Flytta rutan med grafiken Size/Leading Storlek/radavstånd Size: Storlek: Ridges per segment: Åsar per segment: @@ -3968,6 +3969,9 @@ digits Malayalam Malayalam Working CMYK Arbets-CMYK Working CMYK (don't tag) Arbets-CMYK (tagga inte) Working RGB (don't tag) Arbets-RGB (tagga inte) + Don't Color Manage This Document Färghantera inte det här dokumentet + Working RGB: {name} Arbets-RGB: {name} + Working CMYK: {name} Arbets-CMYK: {name} Workspace Arbetslayout Workspace {i} Arbetslayout {i} Wrinkle Tool Skrynkelverktyg @@ -5993,6 +5997,9 @@ theme Appearance Utseende Softness Mjukhet Ambient Light Omgivande ljus Light behind the object Ljus bakom objektet + Move Light to Back Flytta ljus bakåt + Move Light to Front Flytta ljus framåt + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Dra ett ljus för att flytta det. Piltangenterna flyttar ljuset med fokus; Skift flyttar det 15° i taget. Shadows Skuggor Distance from Object Avstånd från objektet Shadow Bounds Skuggans gränser @@ -6791,6 +6798,13 @@ axis Y Axis Y-axel Graphite Pencil Grafitpenna Wax Crayon Vaxkrita Charcoal Dust Koldamm + +# Original procedural circuit brushes. + Tech Circuit Tekniska kretsar + Circuit Trace Kretsbana + Bus Lines Bussledningar + Terminal Nodes Anslutningspunkter + Notched Ribbon Hackat band Double Line Dubbellinje Wave Våg diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 55295f011a..7f3c9b0f2f 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -437,7 +437,7 @@ fn built_in_library_brush_names_are_in_complete_catalogs() { .collect(); names.sort(); names.dedup(); - assert_eq!(names.len(), 43, "{names:?}"); + assert_eq!(names.len(), 46, "{names:?}"); let mut bad = vec![]; for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { let missing: Vec<&String> = names.iter().filter(|n| lang.catalog().plain(n).is_none()).collect(); @@ -3146,6 +3146,48 @@ fn selected_path_reason_is_translated() { } } +/// The refusals of a linked JavaScript file or a library's file outside the read roots, of a file +/// name that fits more than one linked JavaScript file, and of an appearance edit of the art a +/// graph design draws in a graph series have translated rows in languages that translate every +/// message. Each row keeps `{file}` and shows translated both alone and inside the +/// invalid-parameters error of the command that returns it. +#[test] +fn read_root_script_and_graph_design_reasons_are_translated() { + use vectorcraft_engine::EngineError; + let reasons = [ + ("document.export", "a linked JavaScript file is outside the read roots"), + ("swatch.library.copyToUser", "the library's file is outside the read roots"), + ("svgInteractivity.removeFile", "{file} names more than one linked JavaScript file"), + ("appearance", "the art of a graph design in a graph series is drawn again from the design: edit the design to change it"), + ]; + let literals = message_literals(); + for (_, reason) in reasons { + assert!(literals.contains(reason), "{reason}"); + } + let mut rows = [0; 4]; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let (entries, _) = parse_entries(lang.0.source); + for (i, (cmd, reason)) in reasons.into_iter().enumerate() { + let row = entries.iter().find(|(ctx, src, _)| ctx == "@msg" && src == reason).map(|(_, _, tr)| tr.as_str()); + let Some(row) = row else { continue }; + rows[i] += 1; + assert_ne!(row, reason, "{code}"); + assert_eq!(row.matches("{file}").count(), reason.matches("{file}").count(), "{code}: {row}"); + let sample = reason.replace("{file}", "\"a.js\""); + let translated = row.replace("{file}", "\"a.js\""); + assert_eq!(message(lang, &sample), translated, "{code}"); + let error = EngineError::BadParams { cmd: cmd.into(), msg: sample.clone() }.to_string(); + assert_eq!(message(Lang::EN, &error), error); + let shown = message(lang, &error); + assert!(shown.contains(&translated) && !shown.contains(&sample) && shown.contains(&format!("`{cmd}`")), "{code}: {shown}"); + } + } + for ((_, reason), count) in reasons.into_iter().zip(rows) { + assert!(count > 0, "no complete-message catalog has a `@msg` row for {reason:?}"); + } +} + /// The errors a script's `File` and `Folder` objects, `vc.run` and `$.evalFile` raise are /// translated in every language of [`COMPLETE_MESSAGES`], and so is the status bar's line for a run /// that ends with one: the script's name, its place, the command ids and what the script itself diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 1ca8f00dc5..eb1affa964 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3218,6 +3218,7 @@ digits Malayalam Malayalam Sixth Space Altıncı Uzay Size Boyut Size Tile to Art Sanata Boyut Döşeme + Move Tile with Art Döşemeyi Sanatla Taşı Size/Leading Boyut/Satır Aralığı Size: Boyut: Ridges per segment: Segment başına sırtlar: @@ -3827,6 +3828,9 @@ digits Malayalam Malayalam Working CMYK CMYK'de çalışıyor Working CMYK (don't tag) CMYK çalışıyor (etiketlemeyin) Working RGB (don't tag) Çalışan RGB (etiketlemeyin) + Don't Color Manage This Document Bu belgede renk yönetimi yapma + Working RGB: {name} Çalışma RGB'si: {name} + Working CMYK: {name} Çalışma CMYK'sı: {name} Workspace Çalışma Alanı Workspace {i} Çalışma Alanı {i} Wrinkle Tool Kırışıklık Aracı @@ -5877,6 +5881,9 @@ theme Appearance Görünüm Softness Yumuşaklık Ambient Light Ortam Işığı Light behind the object Nesnenin arkasındaki ışık + Move Light to Back Işığı Arkaya Taşı + Move Light to Front Işığı Öne Taşı + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Işığı taşımak için sürükleyin. Ok tuşları odaktaki ışığı taşır; Shift ile 15° adımlarla. Shadows Gölgeler Distance from Object Nesneden uzaklık Shadow Bounds Gölge sınırları @@ -8689,6 +8696,13 @@ axis Y Axis Y ekseni Graphite Pencil Grafit Kalem Wax Crayon Mum Boya Charcoal Dust Kömür Tozu + +# Original procedural circuit brushes. + Tech Circuit Teknik devreler + Circuit Trace Devre izi + Bus Lines Veriyolu hatları + Terminal Nodes Bağlantı düğümleri + Notched Ribbon Çentikli Şerit Double Line Çift Çizgi Wave Dalga diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 942896ce16..d0a68e7fa9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3457,6 +3457,7 @@ digits Malayalam Малаялам Sixth Space Шоста шпація Size Розмір Size Tile to Art Підігнати плитку за малюнком + Move Tile with Art Переміщувати плитку разом із малюнком Size/Leading Розмір/інтерліньяж Size: Розмір: Ridges per segment: Гребенів на сегмент: @@ -4087,6 +4088,9 @@ effect Points Точки Working CMYK Робочий CMYK Working CMYK (don't tag) Робочий CMYK (без позначки) Working RGB (don't tag) Робочий RGB (без позначки) + Don't Color Manage This Document Не керувати кольором цього документа + Working RGB: {name} Робочий RGB: {name} + Working CMYK: {name} Робочий CMYK: {name} Workspace Робоче середовище Workspace {i} Робоче середовище {i} Wrinkle Tool Інструмент «Зморшки» @@ -5247,6 +5251,7 @@ axis Both Обидві @msg every numbered name for {name} is taken in the document's folder усі нумеровані імена для {name} уже зайняті в папці документа @msg No linked files to look for Немає пов’язаних файлів для пошуку @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Зв’язок міститься в символі, дизайні діаграми, візерунку або масці непрозорості й не має місця на полотні +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it графіка дизайну діаграми в ряді даних діаграми перемальовується за дизайном: щоб змінити її, відредагуйте дизайн Services Служби Hide VectorCraft Приховати VectorCraft Zoom Масштаб @@ -5292,6 +5297,7 @@ axis Both Обидві @msg automation filesystem access is not granted: write authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на запис: {path} @msg automation path rejected: outside the read root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для читання {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} шлях автоматизації відхилено: поза кореневими папками для читання {roots}: {path} +@msg a linked JavaScript file is outside the read roots зв’язаний файл JavaScript перебуває поза кореневими папками для читання @msg automation path rejected: outside the write root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для запису {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} шлях автоматизації відхилено: поза кореневими папками для запису {roots}: {path} @msg automation path rejected: path is empty шлях автоматизації відхилено: шлях порожній @@ -5697,6 +5703,9 @@ lighting Light Світло Softness М'якість Ambient Light Навколишнє світло Light behind the object Світло позаду об'єкта + Move Light to Back Перемістити світло назад + Move Light to Front Перемістити світло вперед + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Перетягніть світло, щоб перемістити його. Клавіші зі стрілками переміщують вибране світло; із Shift — кроками по 15°. Shadows Тіні Distance from Object Відстань від об'єкта Shadow Bounds Межі тіні @@ -6417,6 +6426,13 @@ axis Y Axis Вісь Y Graphite Pencil Графітний олівець Wax Crayon Восковий олівець Charcoal Dust Вугільний пил + +# Original procedural circuit brushes. + Tech Circuit Технічні схеми + Circuit Trace Доріжка схеми + Bus Lines Лінії шини + Terminal Nodes Контактні вузли + Notched Ribbon Стрічка з насічками Double Line Подвійна лінія Wave Хвиля diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 32f3a808ed..e5d15ded92 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3812,6 +3812,7 @@ mark-style Japanese Kiểu Nhật Sixth Space Dấu cách 1/6 Size Kích thước Size Tile to Art Kích thước ô theo đối tượng + Move Tile with Art Di chuyển ô cùng đối tượng Size/Leading Cỡ chữ/Giãn dòng Size: Kích thước: Size: {size} Tracking: {tracking} Kích thước: {size} Giãn chữ: {tracking} @@ -4462,6 +4463,9 @@ mark-style Japanese Kiểu Nhật Working CMYK CMYK làm việc Working CMYK (don't tag) CMYK làm việc (không gắn thẻ) Working RGB (don't tag) RGB làm việc (không gắn thẻ) + Don't Color Manage This Document Không quản lý màu cho tài liệu này + Working RGB: {name} RGB làm việc: {name} + Working CMYK: {name} CMYK làm việc: {name} Workspace Không gian làm việc Workspace {i} Không gian làm việc {i} Wrinkle Tool Công cụ nhăn @@ -4698,6 +4702,9 @@ theme Appearance Giao diện @msg automation path rejected: cannot resolve {path}: {e} đường dẫn tự động bị từ chối: không thể phân giải {path}: {e} @msg automation path rejected: outside the read root {root}: {path} đường dẫn tự động bị từ chối: ngoài thư mục gốc đọc {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} đường dẫn tự động bị từ chối: ngoài các thư mục gốc đọc {roots}: {path} +@msg a linked JavaScript file is outside the read roots một tệp JavaScript được liên kết nằm ngoài các thư mục gốc đọc +@msg the library's file is outside the read roots tệp của thư viện nằm ngoài các thư mục gốc đọc +@msg {file} names more than one linked JavaScript file {file} khớp với nhiều hơn một tệp JavaScript được liên kết @msg automation path rejected: outside the write root {root}: {path} đường dẫn tự động bị từ chối: ngoài thư mục gốc ghi {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} đường dẫn tự động bị từ chối: ngoài các thư mục gốc ghi {roots}: {path} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} đường dẫn tự động bị từ chối: duyệt cha bên dưới thư mục không tồn tại: {path} @@ -5080,6 +5087,7 @@ theme Appearance Giao diện @msg no library `{key}` (see {list}) không có thư viện `{key}` (xem {list}) @msg No linked files to look for Không có tệp liên kết để tìm kiếm @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Liên kết nằm trong ký hiệu, thiết kế biểu đồ, họa tiết hoặc mặt nạ độ mờ và không có vị trí trên canvas +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it hình vẽ của một thiết kế biểu đồ trong một chuỗi dữ liệu của biểu đồ được vẽ lại từ thiết kế: hãy sửa thiết kế để thay đổi nó @msg no Live Paint edge near that point không có cạnh tô trực tiếp gần điểm đó @msg no Live Paint face at that point không có mặt tô trực tiếp tại điểm đó @msg no native format không có định dạng gốc @@ -5702,6 +5710,9 @@ theme Appearance Giao diện Softness Độ mềm Ambient Light Ánh sáng môi trường Light behind the object Ánh sáng phía sau đối tượng + Move Light to Back Đưa ánh sáng ra sau + Move Light to Front Đưa ánh sáng ra trước + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Kéo một nguồn sáng để di chuyển nó. Phím mũi tên di chuyển nguồn sáng đang có tiêu điểm; Shift di chuyển theo bước 15°. Shadows Bóng đổ Distance from Object Khoảng cách từ đối tượng Shadow Bounds Giới hạn bóng @@ -6420,6 +6431,13 @@ axis Y Axis Trục Y Graphite Pencil Bút chì than chì Wax Crayon Bút sáp màu Charcoal Dust Bụi than + +# Original procedural circuit brushes. + Tech Circuit Mạch công nghệ + Circuit Trace Đường mạch + Bus Lines Đường bus + Terminal Nodes Nút đầu cuối + Notched Ribbon Dải băng khía Double Line Đường đôi Wave Sóng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0d280fb342..a081c23d5c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3280,6 +3280,7 @@ digits Malayalam 马拉雅拉姆数字 Sixth Space 六分之一空格 Size 大小 Size Tile to Art 使拼贴适合图稿 + Move Tile with Art 拼贴随图稿移动 Size/Leading 字号/行距 Size: 尺寸: Ridges per segment: 每段起伏数: @@ -3926,6 +3927,9 @@ digits Malayalam 马拉雅拉姆数字 Working CMYK 工作 CMYK Working CMYK (don't tag) 工作 CMYK (不加标签) Working RGB (don't tag) 工作 RGB (不加标签) + Don't Color Manage This Document 不对此文档进行色彩管理 + Working RGB: {name} 工作 RGB:{name} + Working CMYK: {name} 工作 CMYK:{name} Workspace 工作区 Workspace {i} 工作区 {i} Wrinkle Tool 褶皱工具 @@ -5006,6 +5010,7 @@ imageTrace Outline 轮廓 @msg every numbered name for {name} is taken in the document's folder 文档所在文件夹中 {name} 的所有带编号的名称均已被占用 @msg No linked files to look for 没有要查找的链接文件 @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 该链接位于符号、图表设计、图案或不透明度蒙版中,在画布上没有位置 +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it 图表系列中图表设计的图稿会根据设计重新绘制:要更改它,请编辑设计 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步骤引用 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必须是由对象 id 组成的数组,而不是 {v} @msg `colors` must be an array of colors, not {v} `colors` 必须是由颜色组成的数组,而不是 {v} @@ -5025,6 +5030,7 @@ imageTrace Outline 轮廓 @msg automation filesystem access is not granted: write authority is absent: {path} 未授予自动化文件系统访问权限:缺少写入权限:{path} @msg automation path rejected: outside the read root {root}: {path} 已拒绝自动化路径:位于读取根目录 {root} 之外:{path} @msg automation path rejected: outside the read roots {roots}: {path} 已拒绝自动化路径:位于所有读取根目录 {roots} 之外:{path} +@msg a linked JavaScript file is outside the read roots 链接的 JavaScript 文件位于所有读取根目录之外 @msg automation path rejected: outside the write root {root}: {path} 已拒绝自动化路径:位于写入根目录 {root} 之外:{path} @msg automation path rejected: outside the write roots {roots}: {path} 已拒绝自动化路径:位于所有写入根目录 {roots} 之外:{path} @msg automation path rejected: path is empty 已拒绝自动化路径:路径为空 @@ -5970,6 +5976,9 @@ lighting Light 光源 Softness 柔和度 Ambient Light 环境光 Light behind the object 光源位于对象后方 + Move Light to Back 将光源移到后方 + Move Light to Front 将光源移到前方 + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. 拖动光源可移动它。方向键移动具有焦点的光源;按住 Shift 以 15° 为步长。 Shadows 阴影 Distance from Object 与对象的距离 Shadow Bounds 阴影边界 @@ -6650,6 +6659,13 @@ axis Y Axis Y 轴 Graphite Pencil 石墨铅笔 Wax Crayon 蜡笔 Charcoal Dust 炭粉 + +# Original procedural circuit brushes. + Tech Circuit 电子电路 + Circuit Trace 电路走线 + Bus Lines 总线 + Terminal Nodes 端子节点 + Notched Ribbon 凹口丝带 Double Line 双线 Wave 波浪 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 649dd294c2..e34ccf9b5c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2705,6 +2705,7 @@ file-filter Templates 範本 @msg every numbered name for {name} is taken in the document's folder 文件所在資料夾中 {name} 的所有編號名稱都已被使用 @msg No linked files to look for 沒有要尋找的連結檔案 @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 此連結位於符號、圖表設計、圖樣或不透明度遮色片中,在畫布上沒有位置 +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it 圖表數列中圖表設計的圖稿會依設計重新繪製:若要變更,請編輯設計 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步驟參照 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必須是由物件 id 組成的陣列,而非 {v} @msg `colors` must be an array of colors, not {v} `colors` 必須是由顏色組成的陣列,而非 {v} @@ -2716,6 +2717,7 @@ file-filter Templates 範本 @msg automation filesystem access is not granted: write authority is absent: {path} 未授予自動化檔案系統存取權:沒有寫入權限:{path} @msg automation path rejected: outside the read root {root}: {path} 已拒絕自動化路徑:位於讀取根目錄 {root} 之外:{path} @msg automation path rejected: outside the read roots {roots}: {path} 已拒絕自動化路徑:位於所有讀取根目錄 {roots} 之外:{path} +@msg a linked JavaScript file is outside the read roots 連結的 JavaScript 檔案位於所有讀取根目錄之外 @msg automation path rejected: outside the write root {root}: {path} 已拒絕自動化路徑:位於寫入根目錄 {root} 之外:{path} @msg automation path rejected: outside the write roots {roots}: {path} 已拒絕自動化路徑:位於所有寫入根目錄 {roots} 之外:{path} @msg automation path rejected: path is empty 已拒絕自動化路徑:路徑是空的 @@ -4538,6 +4540,7 @@ digits Malayalam 馬來亞拉姆數字 Sixth Space 六分之一空格 Size 大小 Size Tile to Art 拼貼符合圖稿大小 + Move Tile with Art 拼貼隨圖稿移動 Size/Leading 大小/行距 Size: 大小: Ridges per segment: 每段起伏數: @@ -5189,6 +5192,9 @@ digits Malayalam 馬來亞拉姆數字 Working CMYK 使用中的 CMYK Working CMYK (don't tag) 工作 CMYK (不加標記) Working RGB (don't tag) 工作 RGB (不加標記) + Don't Color Manage This Document 不對此文件進行色彩管理 + Working RGB: {name} 工作 RGB:{name} + Working CMYK: {name} 工作 CMYK:{name} Workspace 工作區 Workspace {i} 工作區 {i} Wrinkle Tool 皺摺工具 @@ -5802,6 +5808,9 @@ lighting Light 光源 Softness 柔和度 Ambient Light 環境光 Light behind the object 光源位於物件後方 + Move Light to Back 將光源移到後方 + Move Light to Front 將光源移到前方 + Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. 拖曳光源即可移動。方向鍵會移動具有焦點的光源;按住 Shift 以 15° 為間距。 Shadows 陰影 Distance from Object 與物件的距離 Shadow Bounds 陰影邊界 @@ -6457,6 +6466,13 @@ axis Y Axis Y 軸 Graphite Pencil 石墨鉛筆 Wax Crayon 蠟筆 Charcoal Dust 炭粉 + +# Original procedural circuit brushes. + Tech Circuit 電子電路 + Circuit Trace 電路走線 + Bus Lines 匯流排 + Terminal Nodes 端子節點 + Notched Ribbon 凹口緞帶 Double Line 雙線 Wave 波浪 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 50ed3d8ee5..3f89789d5d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -353,6 +353,10 @@ pub struct VectorcraftApp { /// can be driven one simulated frame at a time (production always leaves this `None`). #[cfg(test)] pub(crate) now_override: Option, + /// An error the next tool choice returns before the engine sees it ([`Self::try_select_tool`]), + /// so tests can drive a failed switch (production has no such field). + #[cfg(test)] + pub(crate) fail_next_tool_choice: Option, /// The host iteration [`Self::tick`] ran in (`None`: never): once per iteration, never per pass. tick_iteration: Option, /// The context, viewport and frame whose UI-phase input was already taken in this host diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index 575f11f74f..85588620e8 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -117,7 +117,7 @@ fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) -> bool { /// it scrolls when it is taller than the window. pub(super) fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) { widgets::menu_scroll(ui, |ui| { - ui.set_min_width(230.0); + widgets::menu_min_width(ui, 230.0); render_items(app, ui, items, true, clicked); }); } @@ -137,7 +137,7 @@ pub(super) fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Ite Entry::Sub(label, children) => { ui.menu_button(label, |ui| { widgets::menu_scroll(ui, |ui| { - ui.set_min_width(200.0); + widgets::menu_min_width(ui, 200.0); render_items(app, ui, children, checks || only_toggles(app, children), clicked); }); }); diff --git a/crates/ui-egui/src/menus/context.rs b/crates/ui-egui/src/menus/context.rs index bc11e703e0..68b2a93574 100644 --- a/crates/ui-egui/src/menus/context.rs +++ b/crates/ui-egui/src/menus/context.rs @@ -272,7 +272,7 @@ fn prune(app: &VectorcraftApp, items: Vec) -> Vec { /// The canvas context menu's popup; the item clicked goes in `clicked`, for [`invoke`]. pub fn context_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) { widgets::menu_scroll(ui, |ui| { - ui.set_min_width(200.0); + widgets::menu_min_width(ui, 200.0); // Its toggles already say what they do (Show Rulers, Hide Guides): no check-mark gutter, // except in a submenu of toggles alone (Spelling › Auto Spell Check). render_items(app, ui, &context_items(app), false, clicked); @@ -289,7 +289,7 @@ pub fn ruler_unit_items() -> Vec { /// `clicked`, for [`invoke`]. pub fn ruler_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) { widgets::menu_scroll(ui, |ui| { - ui.set_min_width(160.0); + widgets::menu_min_width(ui, 160.0); render_items(app, ui, &ruler_unit_items(), true, clicked); }); } diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 6efd472675..df3f08b55e 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -679,14 +679,11 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::chrome::step_document(app, true), "window.previousDocument" => crate::chrome::step_document(app, false), "tool.select" => match s("tool") { - Some(t) if vectorcraft_tools::tool_info(&t).is_some() => { - app.select_tool(&t); - Ok(json!({"tool": app.session.tool_id()})) - } + Some(t) if vectorcraft_tools::tool_info(&t).is_some() => app.try_select_tool(&t).map(|()| json!({"tool": app.session.tool_id()})), Some(t) => Err(format!("unknown tool `{t}`")), None => Err("missing `tool`".into()), }, - crate::touch::TOGGLE_MODE => Ok(crate::touch::toggle_mode(app)), + crate::touch::TOGGLE_MODE => crate::touch::toggle_mode(app), crate::addons::pathtools::corner::COMMAND => crate::addons::pathtools::corner::open(app), crate::addons::pathtools::shared_edge::COMMAND => crate::addons::pathtools::shared_edge::open(app), crate::addons::arrange::ZOOM => crate::addons::arrange::zoom(app), @@ -698,10 +695,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::open_effect_dialog(app, p), "ui.recolorDialog" => crate::dialogs::recolor::open(app, p), "ui.cropImage" if !selected_image(app, |_| true) => Err("select an image".into()), - "ui.cropImage" => { - app.select_tool(vectorcraft_tools::cropimage::ID); - Ok(json!({"tool": app.session.tool_id()})) - } + "ui.cropImage" => app.try_select_tool(vectorcraft_tools::cropimage::ID).map(|()| json!({"tool": app.session.tool_id()})), "ui.paramDialog" => { let cmd = s("command").unwrap_or_default(); let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default(); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 24a39c9335..be062c4e73 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -801,7 +801,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Cmd+Shift+F6", "{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view", ), - ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), + ("tool.select", "Select Tool", "", "{tool: id} (see tools) → {tool}; a tool that cannot be chosen returns the error"), ( "tool.setOption", "Tool Option", @@ -812,7 +812,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::touch::TOGGLE_MODE, "Switch Selection and Drawing Modes", "", - "{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)", + "{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}; a tool that cannot be chosen returns the error and leaves the drawing tool as it was. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)", ), ( "effect.dialog", @@ -831,7 +831,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.cropImage", "Crop Image", "", - "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box", + "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box. → {tool}; a tool that cannot be chosen returns the error", ), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), ( @@ -1738,7 +1738,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::scripts_menu::COMMAND, "Run Script", "", - "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (the script is checked again, then script.run runs it by its path, so its #include lines and $.evalFile read beside it), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", + "{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (script.run with installed reads the file once, checks it again and runs that text, so its #include lines and $.evalFile read in the folder that read found), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again", ), ( "ui.checkScript", diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a56bcd7128..a36e9c245d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -265,7 +265,7 @@ pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) { let resp = menu_button(ui, id, rect); egui::Popup::menu(&resp).show(|ui| { crate::widgets::menu_scroll(ui, |ui| { - ui.set_min_width(220.0); + crate::widgets::menu_min_width(ui, 220.0); if panel_menu_items(app, ui, id) { ui.separator(); } diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index 3ef2fc75e4..510b87cde5 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -99,7 +99,7 @@ pub(crate) fn set_limit(app: &mut VectorcraftApp, p: &Value) -> Result Vec { ids } +/// The value `f` gives every character [`glyph_form_items`] acts on: the Type tool's selected +/// range, all the text of the object at a caret (the menu formats all of it there), else every +/// frame of the selected stories. `None` where they differ, or with no type. +fn selection_shared(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option { + match super::character::text_editing(app) { + Some((id, a, b)) if b <= a && super::character::text_story(app).is_none() => { + let NodeKind::Text(t) = &app.session.active()?.doc.node(id)?.kind else { return None }; + let mut acc = super::character::Shared::None; + for r in &t.runs { + acc.add(f(&r.style)); + } + acc.value() + } + _ => super::character::shared(app, f), + } +} + +/// The East Asian glyph form every character [`glyph_form_items`] acts on has ([`selection_shared`]). +fn selection_glyph_form(app: &VectorcraftApp) -> Option { + selection_shared(app, |s| s.glyph_form) +} + +/// Does choosing `form` change the characters [`glyph_form_items`] acts on? Not when they all have +/// it already, unless one of them has a glyph choice, which a form other than the default takes +/// away. +fn glyph_form_changes(app: &VectorcraftApp, form: GlyphForm) -> bool { + selection_glyph_form(app) != Some(form) || (form != GlyphForm::Default && selection_shared(app, |s| s.glyph_alternate.is_none()) != Some(true)) +} + /// The East Asian glyph forms of the panel menu, with Show East Asian Options: each draws the /// selected characters (all the text of the selected type objects without a Type tool range) in /// one form of their font, and is checked when they all have it; a form none of the faces that @@ -989,10 +1018,12 @@ fn glyph_form_items(app: &mut VectorcraftApp, ui: &mut Ui) { let has = super::character::text_style(app).is_some(); let db = FontDb::global(); let faces: Vec<_> = selection_faces(app, ui).into_iter().filter_map(|id| db.face_by_id(id)).collect(); - let current = super::character::shared(app, |s| s.glyph_form); + let current = selection_glyph_form(app); for form in GlyphForm::ALL { + // A width form may be drawn with other widths for the same glyphs (`GPOS`) instead of + // other glyphs. let enabled = match form.feature() { - Some(tag) => faces.iter().any(|f| f.has_substitution_feature(*tag)), + Some(tag) => faces.iter().any(|f| f.has_substitution_feature(*tag) || (form.is_width() && f.has_positioning_feature(*tag))), None => has, }; let checked = form != GlyphForm::Default && current == Some(form); @@ -1000,7 +1031,7 @@ fn glyph_form_items(app: &mut VectorcraftApp, ui: &mut Ui) { ui.separator(); } // Choosing the form every selected character already has changes nothing. - if menu_item(ui, tl!(glyph_form_label(form)), enabled, checked) && current != Some(form) { + if menu_item(ui, tl!(glyph_form_label(form)), enabled, checked) && glyph_form_changes(app, form) { super::character::format(app, json!({"glyphForm": form.id()})); } } @@ -1261,6 +1292,51 @@ mod tests { assert!(forms(&app).iter().all(|(_, f)| *f == GlyphForm::Default), "{:?}", forms(&app)); } + /// At a caret the glyph form items act on all the text of the object, so they are checked, and + /// skipped as changing nothing, by every character's form, not the caret's: with the caret in + /// text of the default form and other text in JIS 78 forms, Revert To Default Forms reverts + /// that text. + #[test] + fn glyph_form_items_at_a_caret_follow_all_the_text() { + let (mut app, id) = editing("亜唖"); + app.session.prefs.show_east_asian_options = true; + let ctx = egui::Context::default(); + let node = NodeId(id.as_u64().unwrap_or(0)); + let forms = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(node).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| r.style.glyph_form).collect::>(), + _ => panic!("text"), + }; + app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 3, "glyphForm": "jis78"})).unwrap(); + select(&mut app, 6, 6); + assert_eq!(selection_glyph_form(&app), None, "the caret's form is not every character's"); + choose(&ctx, &mut app, "Revert To Default Forms"); + assert!(forms(&app).iter().all(|f| *f == GlyphForm::Default), "{:?}", forms(&app)); + app.run("text.setRangeStyle", json!({"id": id, "glyphForm": "jis78"})).unwrap(); + select(&mut app, 6, 6); + assert_eq!(selection_glyph_form(&app), Some(GlyphForm::Jis78)); + } + + /// Choosing the form every character already has is skipped as changing nothing, unless a + /// character has a glyph choice: a form other than the default takes it away, and Revert To + /// Default Forms keeps it. + #[test] + fn a_glyph_choice_lets_the_current_form_be_chosen_again() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + FontDb::global().add_font(alternates_font(false).expect("the test font builds")); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let (mut app, id) = editing("xay"); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(false), "glyphForm": "jis78"})).unwrap(); + select(&mut app, 3, 3); + assert!(!glyph_form_changes(&app, GlyphForm::Jis78), "every character has it"); + assert!(glyph_form_changes(&app, GlyphForm::Default)); + app.run("text.setGlyphAlternate", json!({"id": id, "start": 1, "gid": u1})).unwrap(); + select(&mut app, 3, 3); + assert!(glyph_form_changes(&app, GlyphForm::Jis78), "it takes away the glyph choice"); + app.run("text.setRangeStyle", json!({"id": id, "glyphForm": "default"})).unwrap(); + select(&mut app, 3, 3); + assert!(!glyph_form_changes(&app, GlyphForm::Default), "Revert To Default Forms keeps the glyph choice"); + } + /// Show › Alternates for Current Selection lists the selected character's alternates from the /// font of its text, whatever font the panel browses; a double-click draws the character with /// one. A character without alternates, no selected character and a missing font each show a diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 777db05811..e0e032c086 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -601,7 +601,7 @@ fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: let dresp = dresp.on_hover_text(tl!("Gradient swatches")); egui::Popup::menu(&dresp).show(|ui| { widgets::menu_scroll(ui, |ui| { - ui.set_min_width(200.0); + widgets::menu_min_width(ui, 200.0); let mut chosen = None; let swatches = gradient_swatches(app); if swatches.is_empty() { diff --git a/crates/ui-egui/src/panels/pattern_options.rs b/crates/ui-egui/src/panels/pattern_options.rs index a84f0042f2..0c19c0721c 100644 --- a/crates/ui-egui/src/panels/pattern_options.rs +++ b/crates/ui-egui/src/panels/pattern_options.rs @@ -1,6 +1,6 @@ //! Pattern Options panel: active in pattern editing mode (Object → Pattern → Make / Edit -//! Pattern). Name, Tile Type, Brick Offset, Width/Height, Size Tile to Art with H/V spacing, -//! Overlap, Copies, Dim Copies, Show Tile Edge, Show Swatch Bounds; Save a Copy / Done / Cancel. Outside editing +//! Pattern). Name, Tile Type, Brick Offset, Width/Height, Size Tile to Art with H/V spacing, Move +//! Tile with Art, Overlap, Copies, Dim Copies, Show Tile Edge, Show Swatch Bounds; Save a Copy / Done / Cancel. Outside editing //! mode it lists the document's patterns with an Edit button. use egui::Ui; @@ -94,6 +94,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::check(ui, tl!("Size Tile to Art"), sized, true) { set(app, &name, json!({"sizeTileToArt": !sized})); } + if widgets::check(ui, tl!("Move Tile with Art"), def.move_tile_with_art, true) { + set(app, &name, json!({"moveTileWithArt": !def.move_tile_with_art})); + } row(ui, tl!("H Spacing:"), |ui| { ui.add_enabled_ui(sized, |ui| { if let Some(v) = widgets::num_field(ui, "po-hs", Some(def.h_spacing), unit, 90.0) { @@ -277,4 +280,29 @@ mod tests { frame(&mut app, &ctx, vec![click(false)]); assert!(app.session.active().unwrap().doc.pattern("Dots").unwrap().show_swatch_bounds); } + + /// The Move Tile with Art checkbox sits under Size Tile to Art and turns the option on and off. + #[test] + fn move_tile_with_art_toggles_the_pattern_option() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.run("object.pattern.make", json!({"name": "Dots"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let on = |app: &VectorcraftApp| app.session.active().unwrap().doc.pattern("Dots").unwrap().move_tile_with_art; + let texts = frame(&mut app, &ctx, vec![]); + let find = |t: &str| texts.iter().find(|(s, _)| s == t).map(|(_, r)| *r); + let (sized, at) = (find("Size Tile to Art").expect("Size Tile to Art"), find("Move Tile with Art").expect("the checkbox")); + assert!(at.top() > sized.top(), "under Size Tile to Art"); + let at = at.center(); + let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + assert!(!on(&app)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(true)]); + frame(&mut app, &ctx, vec![click(false)]); + assert!(on(&app)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(true)]); + frame(&mut app, &ctx, vec![click(false)]); + assert!(!on(&app)); + } } diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index 0cb6c726d4..d3cf595666 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -147,16 +147,99 @@ fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) { } } }); - if let Some(d) = app.session.active() { - let (_, cmyk) = vectorcraft_engine::cmd::colormgmt::doc_profiles(&d.doc); - const WORKING: &str = "Working CMYK (don't tag)"; - ui.horizontal(|ui| { - ui.label(tl!("Assign:")); - if let Some(n) = profile_dropdown(ui, "cms-assign", cmyk.as_deref().unwrap_or(WORKING), ProfileKind::Cmyk, Some(WORKING)) { - let v = if n == WORKING { Value::Null } else { Value::String(n) }; - run(app, "edit.assignProfile", json!({ "cmyk": v })); + if let Some(row) = app.session.active().map(|d| AssignRow::of(&d.doc)) { + assign_rows(app, ui, &row); + } +} + +/// Edit › Assign Profile…'s three choices for the active document's color mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AssignChoice { + /// Don't Color Manage This Document: no profile, so the colors show through the working space. + None, + /// The working space of the mode, assigned to the document as its profile. + Working, + /// Another profile of the mode's kind. + Profile, +} + +/// What the Assign rows show for a document: the profile of its color mode (`key` is the +/// `edit.assignProfile` param), the working space of that mode, and the other profiles of that kind. +#[derive(Debug)] +struct AssignRow { + key: &'static str, + mode: vectorcraft_doc::ColorMode, + assigned: Option, + working: String, + others: Vec, +} + +impl AssignRow { + fn of(doc: &vectorcraft_doc::Document) -> Self { + let (rgb, cmyk) = vectorcraft_engine::cmd::colormgmt::doc_profiles(doc); + let st = cms::working_settings(); + let (key, kind, assigned, working) = match doc.color_mode { + vectorcraft_doc::ColorMode::Rgb => ("rgb", ProfileKind::Rgb, rgb, st.rgb), + vectorcraft_doc::ColorMode::Cmyk => ("cmyk", ProfileKind::Cmyk, cmyk, st.cmyk), + }; + let working = cms::canonical_name(&working).to_string(); + let others = cms::profiles().into_iter().filter(|p| p.kind == kind && p.name != working).map(|p| p.name).collect(); + AssignRow { key, mode: doc.color_mode, assigned, working, others } + } + + fn choice(&self) -> AssignChoice { + match &self.assigned { + None => AssignChoice::None, + Some(n) if *n == self.working => AssignChoice::Working, + Some(_) => AssignChoice::Profile, + } + } + + /// The `edit.assignProfile` params for `choice` (with `profile`, the profile picked for + /// [`AssignChoice::Profile`], else the first of the list), or `None` when the document already + /// has it. + fn params(&self, choice: AssignChoice, profile: Option<&str>) -> Option { + let v = match choice { + AssignChoice::None => Value::Null, + AssignChoice::Working => json!(self.working), + AssignChoice::Profile => json!(profile.or(self.others.first().map(String::as_str))?), + }; + (v != json!(self.assigned)).then(|| json!({ self.key: v })) + } +} + +/// The Assign rows: Don't Color Manage This Document, the working space of the document's color +/// mode, and Profile with a list of the other profiles of that kind. A pick runs `edit.assignProfile`. +fn assign_rows(app: &mut VectorcraftApp, ui: &mut Ui, row: &AssignRow) { + let choice = row.choice(); + let mut pick = None; + ui.label(tl!("Assign:")); + if widgets::radio(ui, tl!("Don't Color Manage This Document"), choice == AssignChoice::None, true) { + pick = row.params(AssignChoice::None, None); + } + let working = match row.mode { + vectorcraft_doc::ColorMode::Rgb => tl!("Working RGB: {name}"), + vectorcraft_doc::ColorMode::Cmyk => tl!("Working CMYK: {name}"), + }; + let working = crate::i18n::fmt(working, &[("name", &row.working)]); + if widgets::radio(ui, &working, choice == AssignChoice::Working, true) { + pick = row.params(AssignChoice::Working, None); + } + ui.horizontal(|ui| { + let on = choice == AssignChoice::Profile; + let shown = if on { row.assigned.as_deref() } else { row.others.first().map(String::as_str) }; + if widgets::radio(ui, tl!("Profile:"), on, !row.others.is_empty()) { + pick = row.params(AssignChoice::Profile, shown); + } + let refs: Vec<&str> = row.others.iter().map(String::as_str).collect(); + ui.add_enabled_ui(!refs.is_empty(), |ui| { + if let Some(i) = widgets::dropdown_names(ui, "cms-assign", shown.unwrap_or_default(), &refs, 210.0) { + pick = row.params(AssignChoice::Profile, refs.get(i).copied()); } }); + }); + if let Some(p) = pick { + run(app, "edit.assignProfile", p); } } @@ -235,6 +318,67 @@ mod tests { assert!(panel.iter().all(|(id, _)| cms::ProofTarget::parse(id).is_some_and(|t| t.id() == *id))); } + /// Assign Profile's three choices: no profile, the working space assigned as the profile, or + /// another profile; a choice the document already has runs nothing. + #[test] + fn assign_choices_give_the_profile_params_of_the_color_mode() { + let row = |assigned: Option<&str>| AssignRow { + key: "rgb", + mode: vectorcraft_doc::ColorMode::Rgb, + assigned: assigned.map(str::to_string), + working: "W".into(), + others: vec!["A".into(), "B".into()], + }; + assert_eq!(row(None).choice(), AssignChoice::None); + assert_eq!(row(Some("W")).choice(), AssignChoice::Working); + assert_eq!(row(Some("B")).choice(), AssignChoice::Profile); + assert_eq!(row(None).params(AssignChoice::None, None), None); + assert_eq!(row(Some("B")).params(AssignChoice::None, None), Some(json!({"rgb": null}))); + assert_eq!(row(None).params(AssignChoice::Working, None), Some(json!({"rgb": "W"}))); + assert_eq!(row(Some("W")).params(AssignChoice::Working, None), None); + assert_eq!(row(None).params(AssignChoice::Profile, None), Some(json!({"rgb": "A"}))); + assert_eq!(row(Some("W")).params(AssignChoice::Profile, Some("B")), Some(json!({"rgb": "B"}))); + assert_eq!(row(Some("B")).params(AssignChoice::Profile, Some("B")), None); + let none = AssignRow { others: vec![], ..row(None) }; + assert_eq!(none.params(AssignChoice::Profile, None), None, "no other profile to pick"); + } + + /// The Assign rows follow the document's color mode: an RGB document lists the RGB working space + /// and the RGB profiles and assigns its RGB profile, a CMYK document the CMYK ones, and the other + /// model's profile stays as it was. + #[test] + fn assign_rows_follow_the_document_color_mode() { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + for (mode, key, kind, other) in [("rgb", "rgb", ProfileKind::Rgb, "cmyk"), ("cmyk", "cmyk", ProfileKind::Cmyk, "rgb")] { + app.run("edit.assignProfile", json!({"rgb": null, "cmyk": null})).unwrap(); + app.run("file.documentColorMode", json!({"mode": mode, "convert": false})).unwrap(); + let row = AssignRow::of(&app.session.active().unwrap().doc); + assert_eq!((row.key, row.choice()), (key, AssignChoice::None)); + assert!(!row.others.is_empty() && !row.others.contains(&row.working)); + assert!(row.others.iter().chain([&row.working]).all(|n| cms::profile(n).is_some_and(|p| p.kind == kind)), "{row:?}"); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| settings_rows(&mut app, ui)); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + let working = format!("Working {}: {}", mode.to_uppercase(), row.working); + assert!(shown.contains(&working) && shown.iter().any(|t| t == "Don't Color Manage This Document"), "{shown:?}"); + app.run("edit.assignProfile", row.params(AssignChoice::Profile, None).unwrap()).unwrap(); + let doc = &app.session.active().unwrap().doc; + let (rgb, cmyk) = vectorcraft_engine::cmd::colormgmt::doc_profiles(doc); + let (mine, theirs) = if key == "rgb" { (rgb, cmyk) } else { (cmyk, rgb) }; + assert_eq!((mine.as_deref(), theirs), (row.others.first().map(String::as_str), None), "{other} stays untagged"); + } + } + /// #6419: Edit › Assign Profile… and Color Settings… show the panel scrolled to its Color /// Settings section, which a window shorter than the panel (a floated panel stops at 320 /// points) otherwise shows only after scrolling past the plates and Proof Setup. Once: a diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 6da376c7ca..87fe34953f 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -200,7 +200,8 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { json!("scatter-shapes"), json!("game-trails"), json!("energy-beams"), - json!("dry-media") + json!("dry-media"), + json!("tech-circuit") ] ); let dry = entries.iter().find(|e| under(e) && e.params["library"] == "dry-media").map(|e| e.label.as_str()); @@ -225,6 +226,12 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { click(&mut app, &ctx, "Chalk Grain", Modifiers::NONE, 1.0); assert!(has(&app, "Chalk Grain") && !has(&app, "Wax Crayon")); assert_eq!(undo_len(&app), undo + 1); + let r = app.run("window.brushLibrary", json!({"library": "Tech Circuit"})).unwrap(); + assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("tech-circuit"), Some(3))); + let undo = undo_len(&app); + click(&mut app, &ctx, "Circuit Trace", Modifiers::NONE, 2.0); + assert!(has(&app, "Circuit Trace") && !has(&app, "Bus Lines")); + assert_eq!(undo_len(&app), undo + 1); // Without brushes there is nothing to save. let all: Vec = vectorcraft_brush::library(&app.session.doc().unwrap().doc).into_iter().map(|b| b.name).collect(); app.run("brush.delete", json!({ "names": all })).unwrap(); diff --git a/crates/ui-egui/src/panels/tests_long_popups.rs b/crates/ui-egui/src/panels/tests_long_popups.rs index c562d3540f..966fef1f84 100644 --- a/crates/ui-egui/src/panels/tests_long_popups.rs +++ b/crates/ui-egui/src/panels/tests_long_popups.rs @@ -25,13 +25,17 @@ pub(super) struct PopupWindow<'a> { height: f32, draw: Draw<'a>, texts: Vec<(String, Rect)>, + /// The full texts of the last frame's texts drawn ending in an ellipsis. + shortened: Vec, + /// The last frame's screen-reader nodes, when the window's context has them turned on. + nodes: Vec, } impl<'a> PopupWindow<'a> { pub(super) fn new(app: &mut VectorcraftApp, height: f32, draw: impl FnMut(&mut VectorcraftApp, &mut Ui) + 'a) -> Self { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); - let mut window = Self { ctx, time: 0.0, width: WIDTH, height, draw: Box::new(draw), texts: vec![] }; + let mut window = Self { ctx, time: 0.0, width: WIDTH, height, draw: Box::new(draw), texts: vec![], shortened: vec![], nodes: vec![] }; window.frames(app, 2); window } @@ -45,6 +49,15 @@ impl<'a> PopupWindow<'a> { let mut out = self.ctx.run_ui(input, |ui| draw(app, ui)); out.textures_delta.clear(); self.texts = crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()); + self.shortened = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(text) if text.galley.elided => Some(text.galley.text().to_string()), + _ => None, + }) + .collect(); + self.nodes = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); } /// Makes the window `width` pt wide and draws the frames that takes. @@ -214,7 +227,7 @@ pub(super) fn assert_last_row_scrolls_into_view( /// scroll bar's column, whether or not its list scrolls. The row `widest` is drawn the widest; the /// wheel brings it into view when the popup opens without it. The popup and that row keep their /// place and size while the popup stays open. After the 200 pt window becomes `narrow` pt wide, -/// the popup and its rows in view stay inside the window. +/// the popup, its scroll bar and its rows in view stay inside the window. pub(super) fn assert_rows_stay_on_one_line( app: &mut VectorcraftApp, mut draw: impl FnMut(&mut VectorcraftApp, &mut Ui), @@ -228,6 +241,7 @@ pub(super) fn assert_rows_stay_on_one_line( |window: &PopupWindow| -> Vec<(String, Rect)> { rows.iter().filter_map(|row| window.text(row).map(|rect| (row.clone(), rect))).collect() }; let assert_inside = |window: &PopupWindow, drawn: &[(String, Rect)], name: &str| { let popup = window.popup().unwrap_or_else(|| panic!("the popup is open in the {name} window")); + assert!(popup.right() <= window.width + 0.5, "the popup at {popup:?} ends inside the {name} window, at x = {}", window.width); for (row, rect) in drawn { assert!( rect.left() >= popup.left() && rect.right() <= popup.right() - bar, @@ -333,6 +347,68 @@ fn app() -> VectorcraftApp { VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()) } +/// A row's name too long for the narrow window in [`a_shortened_menu_row_keeps_its_full_text`]. +const LONG_ROW: &str = "A row whose name is far too long for a narrow window"; +/// A dimmed row's name too long for the same window. +const DIMMED_ROW: &str = "A dimmed row whose name is also far too long"; + +/// In a window too narrow for a menu's row, the menu stays inside the window and the row ends in +/// an ellipsis. Screen readers get the row's full text, a click still chooses it, and resting the +/// pointer on it shows the full text in a tooltip inside the window, as it does on a dimmed row. A +/// row that fits is drawn in full and shows no tooltip. +#[test] +fn a_shortened_menu_row_keeps_its_full_text() { + let clicked = std::cell::Cell::new(false); + let draw = |_: &mut VectorcraftApp, ui: &mut Ui| { + let button = ui.button("Open"); + egui::Popup::menu(&button).show(|ui| { + crate::widgets::menu_scroll(ui, |ui| { + // More than the window has, as the menu bar's menus ask for. + crate::widgets::menu_min_width(ui, 230.0); + if ui.button(LONG_ROW).clicked() { + clicked.set(true); + } + let _ = ui.button("Short"); + let _ = ui.add_enabled(false, egui::Button::new(DIMMED_ROW)); + }) + }); + }; + let mut app = app(); + let mut window = PopupWindow::new(&mut app, 300.0, draw); + window.ctx.enable_accesskit(); + window.resize(&mut app, 160.0); + let at = window.text("Open").expect("the button").center(); + window.click(&mut app, at); + let popup = window.popup().expect("the menu is open"); + assert!(popup.left() >= 0.0 && popup.right() <= 160.5, "the menu at {popup:?} is inside the 160 pt wide window"); + let row = window.text(LONG_ROW).expect("the long row"); + assert!(row.right() <= popup.right(), "the long row at {row:?} ends inside the menu at {popup:?}"); + assert!(window.shortened.iter().any(|text| text == LONG_ROW), "the long row ends in an ellipsis: {:?}", window.shortened); + assert!(!window.shortened.iter().any(|text| text == "Short"), "the short row is drawn in full"); + assert!(window.nodes.iter().any(|node| node.label() == Some(LONG_ROW)), "screen readers get the long row's full text"); + let drawn = |window: &PopupWindow, wanted: &str| window.texts.iter().filter(|(text, _)| text == wanted).count(); + assert_eq!(drawn(&window, LONG_ROW), 1, "no tooltip shows before the pointer rests on the row"); + + window.frame(&mut app, vec![Event::PointerMoved(window.text("Short").expect("the short row").center())]); + window.frames(&mut app, 60); + assert_eq!(drawn(&window, "Short"), 1, "the short row shows no tooltip"); + + window.frame(&mut app, vec![Event::PointerMoved(row.center())]); + window.frames(&mut app, 60); + assert_eq!(drawn(&window, LONG_ROW), 2, "resting the pointer on the long row shows its full text in a tooltip"); + let tip = window.texts.iter().filter(|(text, _)| text == LONG_ROW).map(|(_, rect)| *rect).find(|rect| *rect != row); + assert!(tip.is_some_and(|tip| tip.left() >= 0.0 && tip.right() <= 160.5), "the tooltip at {tip:?} is inside the window"); + + assert!(window.shortened.iter().any(|text| text == DIMMED_ROW), "the dimmed row ends in an ellipsis"); + let dimmed = window.text(DIMMED_ROW).expect("the dimmed row"); + window.frame(&mut app, vec![Event::PointerMoved(dimmed.center())]); + window.frames(&mut app, 60); + assert_eq!(drawn(&window, DIMMED_ROW), 2, "resting the pointer on the dimmed row shows its full text in a tooltip"); + + window.click(&mut app, row.center()); + assert!(clicked.get(), "a click on the shortened row chooses it"); +} + /// A long menu opened from a button `y` pt down a window stays at one place on every frame, and so /// does its submenu, whether it opens below the button, above it, or in a window shorter than it. #[test] diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index 72ce8004b5..fd3a30c7bf 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -794,6 +794,196 @@ fn quick_actions_show_on_the_object_tab_only() { assert!(super::quick_actions_shown(&d)); } +/// An app with an Extrude & Bevel square given `params`, and the panel on its Lighting tab with the +/// accessibility tree on. +fn lighting_app(params: Value) -> (VectorcraftApp, egui::Context) { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let mut app = app(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": params})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Lighting").center(), show); + (app, ctx) +} + +/// The lighting widget's node for light `n` (from 1): its screen-reader name and its center. +fn light_node(app: &mut VectorcraftApp, ctx: &egui::Context, n: usize) -> (String, egui::Pos2) { + let nodes = controls(app, ctx); + let prefix = format!("Light {n}:"); + let node = nodes + .iter() + .find(|x| x.role() == egui::accesskit::Role::Slider && x.label().is_some_and(|l| l.starts_with(&prefix))) + .unwrap_or_else(|| panic!("no lighting widget node for light {n}")); + let r = node.bounds().unwrap(); + (node.label().unwrap_or_default().to_string(), egui::pos2(((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)) +} + +fn pointer(pos: egui::Pos2, pressed: Option) -> egui::Event { + match pressed { + Some(pressed) => egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + None => egui::Event::PointerMoved(pos), + } +} + +fn merged_light(app: &VectorcraftApp) -> (f64, f64) { + let (id, params) = effect(app); + let merged = vectorcraft_effects::merged_params(&id, ¶ms); + (merged["lightRotation"].as_f64().unwrap(), merged["lightHeight"].as_f64().unwrap()) +} + +/// Dragging a light in the lighting widget previews its new Rotation and Height while the pointer +/// is down and records one undo step when it is released, at the point of release; the rim of the +/// sphere is Height 0. +#[test] +fn the_lighting_widget_drags_a_light_in_one_undo_step() { + use crate::dialogs::revolve_lights::RADIUS; + use crate::panels::tests_appearance::frame_events; + let (mut app, ctx) = lighting_app(json!({})); + let (name, from) = light_node(&mut app, &ctx, 1); + assert_eq!(name, "Light 1: Rotation 125.3°, Height 30.0°"); + let light = crate::dialogs::revolve_lights::lights(&crate::state::Dialog::new( + "x", + json!({"__effect": "threeD.extrude", "lightRotation": vectorcraft_three_d::DEFAULT_LIGHT.0, "lightHeight": 30.0}), + ))[0]; + let center = from - light.offset() * RADIUS; + let history = undo_len(&app); + frame_events(&ctx, &mut app, vec![pointer(from, None)], show); + frame_events(&ctx, &mut app, vec![pointer(from, Some(true))], show); + // Halfway out along the light's own direction, then onto the rim straight right of the center. + frame_events(&ctx, &mut app, vec![pointer(center + egui::vec2(0.0, -RADIUS * 0.5), None)], show); + assert_eq!(merged_light(&app), (90.0, 60.0), "the light follows the pointer while it is down"); + let rim = center + egui::vec2(RADIUS, 0.0); + frame_events(&ctx, &mut app, vec![pointer(rim, None)], show); + assert_eq!(merged_light(&app), (0.0, 0.0)); + assert_eq!(undo_len(&app), history, "nothing is recorded while the pointer is down"); + // A frame that moves the pointer below the center and releases it there drops the light there. + let below = center + egui::vec2(0.0, RADIUS); + frame_events(&ctx, &mut app, vec![pointer(below, None), pointer(below, Some(false))], show); + frame_events(&ctx, &mut app, vec![], show); + assert_eq!(undo_len(&app), history + 1); + assert_eq!(merged_light(&app), (-90.0, 0.0)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(merged_light(&app), (vectorcraft_three_d::DEFAULT_LIGHT.0, 30.0)); + // A drag that comes back to the press drops the light there, not where it last was. + for event in [pointer(from, None), pointer(from, Some(true)), pointer(center + egui::vec2(0.0, -RADIUS * 0.5), None), pointer(from, None)] { + frame_events(&ctx, &mut app, vec![event], show); + } + frame_events(&ctx, &mut app, vec![pointer(from, Some(false))], show); + frame_events(&ctx, &mut app, vec![], show); + assert_eq!(merged_light(&app), (125.3, 30.0)); +} + +/// With objects whose lights differ in Height, an arrow key that turns the light changes only +/// the Rotation, and each object keeps its own Height. +#[test] +fn a_key_leaves_the_other_setting_as_each_selected_effect_has_it() { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lightRotation": 0.0, "lightHeight": 20.0}})).unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lightRotation": 0.0, "lightHeight": 40.0}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Lighting").center(), show); + let (_, at) = light_node(&mut app, &ctx, 1); + click(&ctx, &mut app, at, show); + let left = egui::Event::Key { key: egui::Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame_events(&ctx, &mut app, vec![left], show); + let light = |id: u64| { + let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + let merged = vectorcraft_effects::merged_params("threeD.extrude", &n.appearance.effects[0].params); + (merged["lightRotation"].as_f64().unwrap(), merged["lightHeight"].as_f64().unwrap()) + }; + assert_eq!((light(a), light(b)), ((1.0, 20.0), (1.0, 40.0))); +} + +/// A press on a light selects it in the list without an undo step, and the button below the sphere +/// moves only the selected light to the back of the object, then back to the front. +#[test] +fn the_lighting_widget_selects_a_light_and_moves_it_behind_the_object() { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let light = + |rotation: f64| json!({"color": "#ffffff", "intensity": 80.0, "rotation": rotation, "height": 30.0, "softness": 0.0, "behind": false}); + let (mut app, ctx) = lighting_app(json!({"lights": [light(0.0), light(180.0)]})); + let history = undo_len(&app); + let (_, second) = light_node(&mut app, &ctx, 2); + click(&ctx, &mut app, second, show); + assert_eq!(undo_len(&app), history, "a press on a light changes nothing"); + let texts = frame_events(&ctx, &mut app, vec![], show); + assert!(texts.iter().any(|(s, _)| s == "Light 2"), "the list shows the pressed light: {texts:?}"); + click(&ctx, &mut app, text_rect(&texts, "Move Light to Back").center(), show); + assert_eq!(undo_len(&app), history + 1); + let behind = |app: &VectorcraftApp| effect(app).1["lights"].as_array().unwrap().iter().map(|l| l["behind"].clone()).collect::>(); + assert_eq!(behind(&app), [json!(false), json!(true)]); + let (name, _) = light_node(&mut app, &ctx, 2); + assert_eq!(name, "Light 2: Rotation 180.0°, Height 30.0°, Light behind the object"); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Move Light to Front").center(), show); + assert_eq!(behind(&app), [json!(false), json!(false)]); + assert_eq!(undo_len(&app), history + 2); +} + +/// A light the pointer took has the keyboard focus: the arrow keys raise and lower it and turn it +/// around the object, 1° a press or 15° with Shift, changing only the setting each press moves, one +/// undo step a frame, and a press at a limit records nothing. +#[test] +fn the_arrow_keys_move_the_focused_light() { + use crate::panels::tests_appearance::{click, frame_events}; + let (mut app, ctx) = lighting_app(json!({"lightRotation": 10.04, "lightHeight": 89.5})); + let (_, at) = light_node(&mut app, &ctx, 1); + click(&ctx, &mut app, at, show); + let history = undo_len(&app); + let key = |key: egui::Key, modifiers: egui::Modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + frame_events(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)], show); + frame_events(&ctx, &mut app, vec![], show); + assert_eq!(merged_light(&app), (10.04, 90.0), "Up leaves the Rotation as it is"); + assert_eq!(undo_len(&app), history + 1); + frame_events(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)], show); + frame_events(&ctx, &mut app, vec![], show); + assert_eq!(merged_light(&app), (10.04, 90.0)); + assert_eq!(undo_len(&app), history + 1, "Height stays at 90° and nothing is recorded"); + // Down then Up in one frame, in that order: back at 90°. + frame_events(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE), key(egui::Key::ArrowUp, egui::Modifiers::NONE)], show); + frame_events(&ctx, &mut app, vec![], show); + assert_eq!(merged_light(&app), (10.04, 90.0)); + assert_eq!(undo_len(&app), history + 1); + // Presses that arrive in one frame all move the light, as one undo step. + let presses = vec![ + key(egui::Key::ArrowLeft, egui::Modifiers::SHIFT), + key(egui::Key::ArrowDown, egui::Modifiers::NONE), + key(egui::Key::ArrowDown, egui::Modifiers::NONE), + ]; + frame_events(&ctx, &mut app, presses, show); + frame_events(&ctx, &mut app, vec![], show); + assert_eq!(merged_light(&app), (25.0, 88.0)); + assert_eq!(undo_len(&app), history + 2); +} + +/// A press on another light selects it and gives it the keyboard focus, so the next arrow key +/// moves that light and leaves the one selected before where it is. +#[test] +fn the_arrow_keys_move_the_light_pressed_last() { + use crate::panels::tests_appearance::{click, frame_events}; + let light = + |rotation: f64| json!({"color": "#ffffff", "intensity": 80.0, "rotation": rotation, "height": 30.0, "softness": 0.0, "behind": false}); + let (mut app, ctx) = lighting_app(json!({"lights": [light(0.0), light(180.0)]})); + let (_, first) = light_node(&mut app, &ctx, 1); + click(&ctx, &mut app, first, show); + let (_, second) = light_node(&mut app, &ctx, 2); + click(&ctx, &mut app, second, show); + let left = egui::Event::Key { key: egui::Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame_events(&ctx, &mut app, vec![left], show); + frame_events(&ctx, &mut app, vec![], show); + let rotations = effect(&app).1["lights"].as_array().unwrap().iter().map(|l| l["rotation"].clone()).collect::>(); + assert_eq!(rotations, [json!(0.0), json!(-179.0)]); +} + #[test] fn changed_settings_lists_only_new_values() { let before = json!({"depth": 50.0, "cap": true, "bevel": "none"}); diff --git a/crates/ui-egui/src/scripts_menu.rs b/crates/ui-egui/src/scripts_menu.rs index 9c890d5c39..c951aac8cb 100644 --- a/crates/ui-egui/src/scripts_menu.rs +++ b/crates/ui-egui/src/scripts_menu.rs @@ -16,6 +16,7 @@ use std::sync::mpsc::{Receiver, TryRecvError}; use std::sync::{Arc, Mutex, MutexGuard}; use serde_json::{Value, json}; +use vectorcraft_engine::cmd::scripts::NOT_LISTED; use vectorcraft_engine::script::folder::{Entry, Listing}; use crate::VectorcraftApp; @@ -241,15 +242,16 @@ pub fn readable(p: &Value) -> bool { p.get("path").and_then(Value::as_str).is_some_and(|path| vectorcraft_engine::file_access::check_read(path).is_ok()) } -/// `ui.runInstalledScript {path}`: run the listed script at `path`. A script that is no longer in -/// the folder is not run, and the folder is read again. The script runs from its file, so its -/// `#include` lines and `$.evalFile` read beside it. +/// `ui.runInstalledScript {path}`: run the listed script at `path`. `script.run` reads the file +/// once, checks it as a listed script and runs that text, so its `#include` lines and `$.evalFile` +/// read in the folder that read found. A script that is no longer in the folder is not run, and the +/// folder is read again. pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { let path = p.get("path").and_then(Value::as_str).ok_or("missing `path`")?; - app.session.installed_script(path).map_err(|e| { - let ctx = app.ui_ctx.clone(); - refresh(app, ctx.as_ref()); - e.to_string() - })?; - app.run("script.run", json!({ "path": path })) + app.run("script.run", json!({ "path": path, "installed": true })).inspect_err(|e| { + if e == NOT_LISTED { + let ctx = app.ui_ctx.clone(); + refresh(app, ctx.as_ref()); + } + }) } diff --git a/crates/ui-egui/src/touch.rs b/crates/ui-egui/src/touch.rs index 7cd1a4ad76..48db775c31 100644 --- a/crates/ui-egui/src/touch.rs +++ b/crates/ui-egui/src/touch.rs @@ -153,19 +153,20 @@ pub(crate) fn is_drawing_tool(id: &str) -> bool { /// [`TOGGLE_MODE`]: from a tool of selection mode, choose the drawing tool chosen last (none yet: /// [`FIRST_DRAWING_TOOL`]); from any other tool, keep it as the drawing tool and choose the -/// Selection tool. → `{tool}`, the tool now active. -pub(crate) fn toggle_mode(app: &mut crate::VectorcraftApp) -> serde_json::Value { +/// Selection tool. → `{tool}`, the tool now active. A switch that fails returns its error and +/// leaves the drawing tool to return to as it was. +pub(crate) fn toggle_mode(app: &mut crate::VectorcraftApp) -> Result { let now = app.session.tool_id().to_string(); - let next = if selects(&now) { - app.ui.touch_drawing_tool.clone().unwrap_or_else(|| FIRST_DRAWING_TOOL.to_string()) + let (next, outgoing) = if selects(&now) { + (app.ui.touch_drawing_tool.clone().unwrap_or_else(|| FIRST_DRAWING_TOOL.to_string()), None) } else { - if is_drawing_tool(&now) { - app.ui.touch_drawing_tool = Some(now); - } - "selection".to_string() + ("selection".to_string(), is_drawing_tool(&now).then_some(now)) }; - app.select_tool(&next); - serde_json::json!({"tool": app.session.tool_id()}) + app.try_select_tool(&next)?; + if let Some(tool) = outgoing { + app.ui.touch_drawing_tool = Some(tool); + } + Ok(serde_json::json!({"tool": app.session.tool_id()})) } #[cfg(test)] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 36145224d5..0738144ade 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -45,7 +45,7 @@ pub use drag::{ }; #[cfg(test)] use dropdowns::combo_button_id; -pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field_named, combo_named, field_focused_in, popover_is_open}; +pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field_named, combo_named, field_focused_in, menu_min_width, popover_is_open}; pub use dropdowns::{ dropdown, dropdown_icon_names, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, panel_scroll, popover, set_menu_floor, strip_scroll, diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index 1539be8700..7f088f25ef 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -443,7 +443,9 @@ fn popup_anchor(ui: &Ui) -> Option<(Rect, bool)> { /// reachable on small windows and in a floating panel's short OS window. Its scroll bar has a column /// of its own, so it never covers the shortcuts or the submenu arrows (#2086, #5347). When the list /// scrolls, that column widens the popup instead of narrowing the items, so their labels stay on one -/// line. +/// line. In a window too narrow for the items, the list is as wide as the window allows, and a row +/// that does not fit ends in an ellipsis and shows its full text in a tooltip. Screen readers and +/// keyboard selection still use the full text. /// /// The list's height comes from the room below and above the button, row or point the popup opens /// from. A popup with an id of its own measures from its own top instead. @@ -463,14 +465,13 @@ pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R menu_room(floor, top, tallest) }; // The items' width on the first frame of this showing, when the scroll area does not yet show - // its bar. Later frames lay the items out at least that wide, and the bar's column is added to - // the popup's width. The width is kept until the popup closes. + // its bar. Later frames lay the items out that wide, up to the widest the window allows, and + // the bar's column is added to the popup's width. The width is kept until the popup closes. let key = ui.next_auto_id().with("menu-scroll-width"); let frame = ctx.cumulative_frame_nr(); let first = ctx.data(|d| d.get_temp::<(u64, f32)>(key)).filter(|(shown, _)| shown + 1 >= frame).map(|(_, width)| width); let scroll = list_scroll_style(); - // The widest the items can be with the popup's margins and the bar's column inside the window. - let widest = (ctx.content_rect().width() - margins.x - scroll.allocated_width()).max(0.0); + let widest = menu_list_widest(ui); ui.scope(|ui| { ui.spacing_mut().scroll = scroll; // The minimum lets the list grow past a popup's default 400 pt size; it still shrinks to @@ -479,9 +480,20 @@ pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R let out = area.show(ui, |ui| { // Always the same child, so the items' ids stay the same on the frame the width changes. let max = ui.max_rect(); - let width = first.map_or(max.width(), |first| first.min(widest).max(max.width())); + let width = first.map_or(max.width(), |first| first.max(max.width()).min(widest)); let rect = egui::Rect::from_min_size(max.min, egui::vec2(width, max.height())); - ui.scope_builder(egui::UiBuilder::new().max_rect(rect).layout(*ui.layout()), add_contents).inner + let painted = ctx.graphics(|g| g.get(ui.layer_id()).map_or(0, |list| list.all_entries().len())); + let inner = ui + .scope_builder(egui::UiBuilder::new().max_rect(rect).layout(*ui.layout()), |ui| { + // The first frame measures the items at their full width. + if first.is_some() { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); + } + add_contents(ui) + }) + .inner; + show_shortened_rows(ui, painted); + inner }); let width = first.unwrap_or(out.content_size.x); ctx.data_mut(|d| d.insert_temp(key, (frame, width))); @@ -490,6 +502,64 @@ pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R .inner } +/// The widest the items of a [`menu_scroll`] list can be with the popup's margins and the scroll +/// bar's column inside the window. +fn menu_list_widest(ui: &Ui) -> f32 { + let margins = egui::Frame::popup(ui.style()).total_margin().sum(); + (ui.ctx().content_rect().width() - margins.x - list_scroll_style().allocated_width()).max(0.0) +} + +/// Makes a menu at least `width` wide, or as wide as the window allows the items of a +/// [`menu_scroll`] list when that is less, so the menu stays inside a narrow window. +pub(crate) fn menu_min_width(ui: &mut Ui, width: f32) { + ui.set_min_width(width.min(menu_list_widest(ui))); +} + +/// For each text that `ui`'s layer drew after its first `painted` shapes and that ends in an +/// ellipsis, the clickable row it is drawn in shows the full text in a tooltip that wraps to fit +/// inside the window. +fn show_shortened_rows(ui: &Ui, painted: usize) { + let ctx = ui.ctx(); + let layer = ui.layer_id(); + let shortened: Vec<(egui::Pos2, egui::text::LayoutJob)> = ctx.graphics(|g| { + let Some(list) = g.get(layer) else { return vec![] }; + list.all_entries() + .skip(painted) + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(text) if text.galley.elided => { + let job = egui::text::LayoutJob { + sections: text.galley.job.sections.clone(), + text: text.galley.job.text.clone(), + language: text.galley.job.language.clone(), + ..Default::default() + }; + Some((Rect::from_min_size(text.pos, text.galley.size()).center(), job)) + } + _ => None, + }) + .collect() + }); + if shortened.is_empty() { + return; + } + let rows: Vec<(egui::Id, Rect)> = + ctx.viewport(|vp| vp.this_pass.widgets.get_layer(layer).filter(|w| w.sense.senses_click()).map(|w| (w.id, w.rect)).collect()); + for (at, job) in shortened { + let Some(response) = rows.iter().rfind(|(_, rect)| rect.contains(at)).and_then(|(id, _)| ctx.read_response(*id)) else { continue }; + let tip = |ui: &mut Ui| { + let margins = ui.spacing().menu_margin.sum().x + 2.0 * ui.spacing().item_spacing.x; + ui.set_max_width(ui.spacing().tooltip_width.min(ui.ctx().content_rect().width() - margins).max(0.0)); + ui.add(egui::Label::new(job).wrap()); + }; + // A dimmed row shows its full text too. + if response.enabled() { + response.on_hover_ui(tip); + } else { + response.on_disabled_hover_ui(tip); + } + } +} + /// A column of tool or panel buttons (the toolbar, the dock's icon column) that scrolls when the /// window is too short for it: with the mouse wheel, or the scroll bar shown while the pointer is /// over it. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e6f3621e8e..d52720f29e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -973,7 +973,10 @@ the path a stroke draws, continues or merges into is not selected afterwards), ` off by default, and while it is on a stroke merges only with selected blobs), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil, Paintbrush), `size` (pt; Blob Brush, Eraser), and `angle` (°, −180 to 180; 0) and `roundness` (%, 0 to 100 for the Blob Brush, where 0 is drawn as 0.1, and 1 to 100 for -the Eraser; 100) of the tip (Blob Brush, Eraser); the tip keeps its angle on the page along a stroke. `ui.dialog.confirm` runs +the Eraser; 100) of the tip (Blob Brush, Eraser); the tip keeps its angle on the page along a stroke. `sizeMode` and +`sizeVariation` (pt) vary the size (Blob Brush: `fixed` or `pressure`). The Eraser also has `angleMode`, +`angleVariation` (°, 0 to 180), `roundnessMode` and `roundnessVariation` (%, 0 to 100), and its three modes are each +`fixed`, `random`, `pressure`, `tilt`, `bearing` or `rotation`, as `path.eraseRegion` takes them. `ui.dialog.confirm` runs `tool.setOption {tool, values}`. `ui.pointer` events also take `holdMs` (0..60000): the pointer then holds still that long, button down, before the diff --git a/docs/gaps.md b/docs/gaps.md index d0140731e2..1275b6dd38 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (G5: built-in Weather Icons symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow); estimate 5–15 → 4–14 h; previously: 3D: the seams of white-light Extrude & Bevel, Inflate and Rotate, and the face seams of the 3D (Classic) effects, have round joins instead of miters; previously: PDF import: preserve Type 3 font text runs as named groups , or as live text in TextAs::Text mode if ToUnicode exists on non-colour fonts (TextAs::Outlines produces grouped outlines), #6740; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; 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: G5: built-in Weather Icons symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow); estimate 5–15 → 4–14 h; previously: 3D: the seams of white-light Extrude & Bevel, Inflate and Rotate, and the face seams of the 3D (Classic) effects, have round joins instead of miters; previously: PDF import: preserve Type 3 font text runs as named groups , or as live text in TextAs::Text mode if ToUnicode exists on non-colour fonts (TextAs::Outlines produces grouped outlines), #6740; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -36,6 +36,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard |---|---|---| | 2026-11-14 | minor | G12: Symbols panel context menu: right-click on a symbol in thumbnail or list view displays a context menu (Place Symbol Instance, Edit Symbol, Duplicate Symbol, Delete Symbol, Break Link to Symbol, Select All Instances), selecting the symbol first if unselected; scores and estimates unchanged. | | 2026-11-14 | minor | G5: Weather Icons joins Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons as a sixth built-in symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow; original procedural vector symbols generated in code, no default 9-slice); estimate decremented (5–15 → 4–14 h). | +| 2026-11-14 | minor | G5: 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 | G12: Layers panel row context menu (Options…, Duplicate, Delete, Lock Others/Show All/Hide Others/Unlock All, Locate Object, Collect for Export) with selection handling and undo tracking. Scores and estimates unchanged. | | 2026-11-13 | minor | PDF import: preserve Type 3 font text runs as named groups ``, or as live text in `TextAs::Text` mode if `/ToUnicode` exists on non-colour fonts (`TextAs::Outlines` produces grouped outlines) (#6740); scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | @@ -660,8 +661,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (nine original libraries generated in code: Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes (one - bristle brush for each tip shape), Pattern Borders, Scatter Shapes, Game Trails, Energy Beams and Dry Media (grainy chalk, pencil, crayon and charcoal art brushes); User Defined; Other Library… reads the brushes of any document VectorCraft opens); a + its own (ten original libraries generated in code: Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes (one + bristle brush for each tip shape), Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media (grainy chalk, pencil, crayon and charcoal art brushes) and Tech Circuit (Circuit Trace, Bus Lines and Terminal Nodes); User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo step (applied to the selected paths in the same step), dragging one onto a path adds and applies it and @@ -682,9 +683,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Fine Grain, Canvas Weave, Burlap Sack, Cracked Glaze, Mosaic Floor, Quilted Squares, Leaded Glass and Damp Paper, painted with the texture effects; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the eight shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the ten shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons and Weather Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write. -- **Estimate:** 4–14 h. +- **Estimate:** 3–13 h (estimated). ## G3 3D and Materials @@ -887,6 +888,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 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-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins (the seams of Revolve and of colored lights, and 3D (Classic) Wireframe edges, were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). Scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-09 | minor | G4 review (#6084): positional variants no supported feature draws can be applied as glyph choices in both Glyphs views; unlisted glyphs still respect name ownership. OpenType style options keep only a valid last on-value when enabling a set. Existing contextual/ligature lookup and unsupported-feature gaps remain; estimate unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 9336ea35ca..ceb56138fc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,8 @@ # VectorCraft MCP server > **Last updated:** 2026-11-14 · **Change:** Pencil ignores Smart Guides and Snap to Point and shows no Smart Guides, #7252; no response-shape change. Previously: Pencil continuation, straight-line and closing cursors; scores and estimates unchanged; no response-shape change. Previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; no response-shape change. Previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained. +> +> Tech Circuit update (2026-11-14): three original procedural brushes; ten built-in brush libraries (G5). `vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio (newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to @@ -826,15 +828,30 @@ the `effect.*` commands and `appearance.addEffect`, and the paint, stroke and gr `item`, which is a row of the group's own stack. The Appearance panel and `appearance.setActiveItem` read the selected group's own rows. -The graph draws the art of a column or marker design in a series again from the design, so the commands that add, -change, move or remove fill and stroke rows, effects or graphic styles leave that art out: the `appearance.*` commands -listed above, `effect.apply`, `appearance.addEffect` and the other `effect.*` commands that edit, move or remove an -effect, `graphicStyle.apply` (and `graphicStyle.addFromLibrary` with `apply`), `transparency.set` with a fill or stroke -`item`, and `appearance.copyFrom` as the target. The art is any object inside a series group other than a path directly -in it, whether given by id, selected, or reached through `target: "contents"`. The commands edit the other objects they -are given, and a command whose objects are all design art is an error. `appearance.transfer` with design art as its -`target`, or as its `source` without `copy`, is an error. The paint commands leave the art out as well, without an -error. To change the art, edit the design with `graph.design`. +When a graph is drawn again (a change to its data, its type, or its column or marker designs), it draws the art of a +column or marker design in a series again from the design, and an edit made to that art is lost. The art is any object +inside a series group other than a path directly in it, a legend swatch's included, whether given by id, selected, or +reached through `target: "contents"`. These commands leave that art out, edit the other objects they are given, and +are an error when all their objects are design art: the `appearance.*` commands listed above, `effect.apply`, +`appearance.addEffect` and the other `effect.*` commands that edit, move or remove an effect, +`effect.expandAppearance` (which also leaves the art inside a selected graph or series group as it was, unless an +effect of that group turns the whole group into other art, and is disabled when only design art is selected or +given), `threeD.mapGraphic`, +`graphicStyle.apply`, `graphicStyle.addFromLibrary` with `apply`, `graphicStyle.breakLink`, `transparency.set`, the +`paint.freeform.*` commands that edit points and lines, and `appearance.copyFrom` as the target. +`appearance.transfer` with design art as its `target`, or as its `source` without `copy`, is an error. +`paint.setFill`, `paint.setStroke`, `paint.swap`, `paint.default`, `paint.invert` and `paint.complement` with an +`item`, `stroke.set`, `stroke.weightUp`, `stroke.weightDown`, `stroke.setAdvanced`, `paint.editGradient`, +`paint.setGradientGeom` and `object.setOverprint` leave the art out without an error. When the first selected object is design art, the commands +do not use the Appearance panel's active row. When the first object given is design art, rows and effects given with +`aligned: true` are rows of its stack in `appearance.setItem`, `appearance.removeItem`, `appearance.duplicateItem`, +`transparency.set` with an `item`, `effect.apply`, `appearance.addEffect` and the other `effect.*` commands that edit, +move or remove an effect: each other object edits the row or effect equal to it, and an object without one is left as +it is. When no object has one, `appearance.setItem` and `appearance.removeItem` leave the document as it is, as they do +for a row no object has, and the other commands are an error. `appearance.moveItem`, `effect.move` and +`appearance.duplicateItem` with `to` are an error when the art's stack differs from the other objects', and +`appearance.duplicateItem` with `to` is also an error when two or more other objects are given, as it is without the +art. To change the art, edit the design with `graph.design`. The graph also stores a paint or stroke weight that one of these commands changes on the topmost visible fill or stroke of a path directly in a series: a weight set with `stroke.set`, `stroke.weightUp` or `stroke.weightDown`; a gradient @@ -1043,6 +1060,11 @@ styles and brushes. `color.removeProfile`, `view.proofSetup`, `view.proofColors`, `view.overprintPreview`, `view.separationsPreview` and `swatch.setSpot` reject a param of the wrong type, such as `"bpc": "no"`, `"on": 1`, `"to": 5` or `"colors": "#fff"`, and change nothing. Omitting a param or giving null keeps its default. +The Separations panel's Assign rows (where Edit › Assign Profile… goes) act on the profile of the document's color +mode: Don't Color Manage This Document runs `edit.assignProfile` with null for that model (no profile, so the colors +show through the working space), Working RGB or Working CMYK with the working space's name (that profile stays +assigned when the working space changes), and Profile with another profile of that kind. A choice the document +already has runs nothing, and the other model's profile stays as it is. SVG import keeps what the canvas edits live. `` becomes a pattern swatch. A `` with `` becomes a symbol (named after its `data-name`, else its id) with an instance per ``; a `` that shows it differently @@ -1112,7 +1134,8 @@ gradient meshes whose every point is at 0% opacity, and paths without effects wh An image or a mesh with a Crop Marks effect, or with a Vector Halftone effect that makes dots, is measured. Such objects in the art of a symbol are left out of its instances too, and an instance whose symbol holds nothing else is left out. In the art of a dynamic symbol, an object that an instance's fill, stroke or opacity makes -the plot draw is measured. +the plot draw is measured. The art of an instance that goes through 9-slice guides is placed through them first, and +such objects are then left out of the placed art. A document whose only visible art is left out is refused, and Export As refuses an HP-GL plot of it before the save dialog. A PDF of that page, from either command, carries no Preserve Editing data; when Preserve Editing is on (as it is in VectorCraft Default), the export comes back with a warning that says so. A `.ai` export with @@ -1280,8 +1303,10 @@ opens at its size in points, the file's numbers times the `UserUnit` (one above such a page opens from that data at the page's size. `linkPages: true` (File › Open's Import PDF dialog: Import PDF pages as links) opens each page `pages` picks as a placed document linked to that page of the file, as `file.place` with `link` places one, instead of as editable art. -Each page gets an artboard of its `cropTo` box (`bounding`: its art's bounds), in a row from the left 36 pt apart, and -a layer named after the page (`Page 2`). The document opens in CMYK when most of those pages are painted mostly in +Each page gets an artboard of its `cropTo` box (`bounding`: its art's bounds; a blank page gets its crop box), in a row from the left 36 pt apart, and +a layer named after the page (`Page 2`). A PDF-compatible `.ai` with `cropTo: "crop"` gets its trim boxes, as it does +when it opens as art. A page on its trim box shows its bleed box round the artboard, and the pages give the document +their bleed; the artboards are then 36 pt apart plus the bleed on both sides. The document opens in CMYK when most of those pages are painted mostly in CMYK, unless `colorMode` names a mode. All the pages' objects share one copy of the file. The Links panel lists each page, and `links.embed` makes a page editable art. `document.open` refuses `linkPages` for a file that is not a PDF or a PDF-compatible `.ai`, for a file given as @@ -2554,6 +2579,9 @@ The rest of this section is VectorCraft's own rules: `The Scripts folder cannot be read`. - A listed file is checked again when it is chosen. A file that is gone, is no longer a `.js` or `.jsx` file under the folder, or is or lies behind a link ends with `the script is no longer in the Scripts folder`, and nothing runs. + The file is read once for that check, and the text that read checked is the text that runs; its `#include` lines + and `$.evalFile` read in the folder that read found. `script.run {path, installed: true}` runs a listed script the + same way. - The web build has no Scripts folder, and neither has a run with `VECTORCRAFT_NO_PREFS` set. `script.listInstalled {}` returns the same listing: `{folder, scripts: [{path, label, folders}], truncated, @@ -4586,9 +4614,9 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: nine generated in code (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle +Brush libraries are read-only sets of brushes: ten generated in code (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes with one bristle brush for each tip shape, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, and Dry Media with the Chalk Grain, Graphite Pencil, Wax Crayon and -Charcoal Dust art brushes, id `dry-media`), the `.vectorcraft` files in the user library folder of the desktop app (User +Charcoal Dust art brushes, id `dry-media`, and Tech Circuit with Circuit Trace (art), Bus Lines and Terminal Nodes (pattern), id `tech-circuit`), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name). @@ -4845,6 +4873,14 @@ and `height`, so a new `hSpacing` changes the tile's width. `pattern.options` st `object.pattern.edit` edits the pattern that fills or strokes a selected object, and `pattern.options` changes the pattern being edited; each returns an error when there is no such pattern. `pattern.delete` requires `name`. +With `pattern.options {moveTileWithArt: true}` (Move Tile with Art), a move of all of the tile art in pattern editing +mode moves the tile by the same offset: `object.move`, `object.nudge`, `object.transform` with a translation, and drags +with the Selection tool. A move of only some of the art, a move of direct-selected anchors, a copy, a rotation and a +scale leave the tile where it is. The option is off for a new pattern and works the same with `sizeTileToArt` on. These +details are VectorCraft's own rules; the help page says only that the option moves the tile along with the art +(). +`pattern.list` reports it as `moveTileWithArt`. A `pattern.options` call that changes nothing records no undo step. + ```json {"name":"run_command","arguments":{"command":"pattern.options","params":{"showSwatchBounds":true}}} {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"patternTileEdgeColor","value":"#ff4f4f"}}} @@ -5502,11 +5538,28 @@ moves that point alone as the focal point, with or without Shift (Shift keeps th focal points stay highlighted where it left them; Escape during a drag puts the path back and keeps them, and switching to another tool clears them. +`path.eraseRegion` (the Eraser) takes a variation mode for each of its tip's values, as the variation menus of +Eraser Tool Options set them ([Erase paths using the Eraser +tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html)): +`angleMode` with `angleVariation` (degrees, 0 to 180), `roundnessMode` with `roundnessVariation` (percent, 0 to 100) +and `sizeMode` with `sizeVariation` (points, 0 or more), each `"fixed"` (the default), `"random"`, `"pressure"`, +`"tilt"`, `"bearing"` or `"rotation"`. A value that varies takes 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 the pen presses hardest. Tilt goes from less with the pen lying flat to more with it upright. Bearing and +Rotation go from less at −180° to more at 180° of the way the pen leans or its barrel turns. The tip at each point of +the drag takes what the pen read there (the points' pressure, tilt and rotation, as `path.freehand` takes them); a +drag without that reading, such as a mouse drag, erases at the value itself. One Random value per drag, which way +Tilt, Bearing and Rotation run, and the value itself without a reading are VectorCraft's own rules. +The command refuses another mode, and a variation below 0 or past its range, and erases nothing. In the desktop app +the Eraser's tip outline at the pointer takes the angle, roundness and size the pen gives while it erases. +`tool.setOption {tool: "eraser"}` takes the same keys. + These commands refuse a string or another value that is not a number with `` `dy` must be a number, not "10" ``, naming the key; the document does not change and no undo step is recorded: `t`, `dx` and `dy` in `path.reshapeSegment` and `object.blend.spine.reshapeSegment`; `x`, `y` and `t` in `path.curvatureEdit`; `x` and `y` in `path.convertAnchor`; `t` in `path.insertAnchor`; `fidelity` and the `within` of `redraw` in `path.freehand` (and in -`brush.freehand`, which runs it); `size`, `sizeVariation`, `angle` and `roundness` in `path.eraseRegion`; +`brush.freehand`, which runs it); `size`, `sizeVariation`, `angle`, `angleVariation`, `roundness` and +`roundnessVariation` in `path.eraseRegion`; `size`, `sizeVariation`, `angle`, `roundness` and `fidelity` in `path.blob`; `width` in `path.eraseSegments`; `radius` and `fidelity` in `path.smoothRegion`; `tolerance` in `path.joinScrub`; `dx` and `dy` in `path.moveAnchors`; and `dx`, `dy`, `tol` and `radius` in `path.reshape`. A null is read as a key left out. `path.convertAnchor` refuses @@ -5669,7 +5722,17 @@ the colour bars are process, spot and black-tint patches. Layers whose Print opt The Output and Advanced sections convert colours and write real text. `output: {conversion, destination}` converts every colour into the destination profile's model (`destination`; CMYK of another profile too) or only the colours of the other model (`preserveNumbers`: colours already in it keep their numbers); grey stays grey, images are converted -too and printer's marks are not. The destination is any profile `edit.colorSettings` lists (default: the document's +too and printer's marks are not. With `destination` and an sRGB destination, a gray or RGB image that carries an +ICC profile is converted from that profile to sRGB with the color settings' intent and written without +it, at 16 bits a sample when it has them, is not resampled or recompressed and the file is PDF 1.5 or later (an image +whose profile the color engine can't read, or whose pixels can't be decoded, keeps its profile, with a warning); without +conversion it keeps its profile. With `destination` and a CMYK destination, or an RGB one other than sRGB that images +are converted to, an RGB image that carries an ICC profile is converted from that profile to sRGB with the same intent +and then to the destination, instead of having its numbers read as sRGB (a profile the color engine can't read leaves +them read as sRGB, with a warning); a gray image keeps its profile, as gray is not converted. An RGB destination other +than sRGB that is the document's own RGB profile leaves RGB colors and RGB images as they are, so a tagged RGB image keeps its profile +there. +`preserveNumbers` still reads an image's numbers as sRGB. The destination is any profile `edit.colorSettings` lists (default: the document's for its colour mode). `profiles` (`all`, `destination`, or `taggedSource` for a document assigned profiles with `edit.assignProfile`) writes colours in ICC-based spaces with their profiles embedded: CMYK with the destination's or the document's CMYK profile, RGB as sRGB, grey with the sRGB tone curve (PDF/A, PDF/X-3 and PDF/X-4 files are @@ -6339,7 +6402,10 @@ desktop app) instead and undoes nothing ([the help page](https://helpx.adobe.com From the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool it chooses the drawing tool chosen last in the window, or the Pencil tool when none has been chosen yet; from any other tool it chooses the Selection tool, and the tool left becomes the drawing tool unless it is the Hand, Rotate View, Zoom or Print -Tiling tool. It returns `{tool}` and adds no undo step. Pencil as the first drawing tool and the list of selection tools +Tiling tool. It returns `{tool}` and adds no undo step. When the tool cannot be chosen it returns the error, the tool +and the drawing tool stay as they were, and it adds no undo step; `tool.select`, `ui.cropImage`, +`addon.pathtools.cornerTool` and `addon.pathtools.sharedEdgeTool` also return the error when their tool cannot be +chosen. Pencil as the first drawing tool and the list of selection tools are VectorCraft's own rules. A tap in Presentation Mode runs nothing. Under VectorCraft's own rule, a tap on the canvas while a modal dialog is open runs nothing, and fingers already down when it opens make no tap when they lift, during the dialog or after it closes. @@ -9244,13 +9310,20 @@ as outlines. `fwid`, `hwid`, `twid` or `qwid` ([feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)), or `"default"`, the font's default glyphs. A character holds one form at a time, so setting one replaces another. The text keeps its characters, and a character the font has no glyph of that form for keeps its own. Runs that differ only -in their form are shaped as one piece of text. The form is saved only when it is not `"default"`, character styles -carry it, PDF draws the glyphs the canvas draws, and live SVG text writes it in `font-feature-settings`. Another value -is refused with `` `glyphForm` must be one of "traditional", … ``. One undo step, and a `text.setFormat` call that -gives the form the characters already have records none. With Show East Asian Options on, the Glyphs panel menu lists -the ten forms and Revert To Default Forms: a form is checked when every selected character has it, choosing it again -does nothing, and it is disabled when none of the faces that draw the selected text, fallback faces included, has a -feature for it. +in their form are shaped as one piece of text. A form other than `"default"` also takes away the characters' glyph +choice (`text.setGlyphAlternate`), which would otherwise keep drawing its own glyph; `"default"` keeps it. The four +width forms set the characters on a fixed width, so Proportional Metrics (`palt`) is not applied with them and the +font's pair kerning (`kern`, `apkn`) is turned off, in shaping and in SVG output (`font-kerning: none`); +`proportionalMetrics` and `kerning` keep their values. The form is saved only when it is not `"default"`, character +styles carry it, PDF draws the glyphs the canvas draws, and live SVG text writes it in `font-feature-settings`. Another +value is refused with `` `glyphForm` must be one of "traditional", … ``. One undo step, and a `text.setFormat` call +that gives the form the characters already have records none. With Show East Asian Options on, the Glyphs panel menu +lists the ten forms and Revert To Default Forms. They act on the Type tool's selected range, all the text of the object +at a caret, or all the text of the selected type objects. A form is checked when every one of those characters has +it, and choosing it again does nothing unless one of them has a glyph choice, which the form takes away. A form is +disabled when none of the faces that draw the selected text, fallback faces included, has a feature for it: a `GSUB` feature, or for the width +forms a `GSUB` or `GPOS` feature +([fwid](https://learn.microsoft.com/en-us/typography/opentype/spec/features_fj#tag-fwid)). ```json {"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"glyphForm":"jis78"}}} @@ -9733,7 +9806,9 @@ neighbors, the kerning of the two characters keeps it and the characters after i wider or narrower character moves the characters after it by the change in its width. Touch Type gives the character before it a kerning value of its own (with `dx`, and with any other change where the font kerned the character with its neighbors), and that character can then move by the font's kerning pair with the one before -it, as with any style change to one character (`text.setRangeStyle`). The type stays editable. +it, as with any style change to one character (`text.setRangeStyle`). The type stays editable. The characters of type +on an open path that are not drawn because they fall before the path's start cannot be selected with the tool, and +`text.touchType` returns an error for them. ```json {"name":"run_command","arguments":{"command":"text.touchType","params":{"id":7,"start":1,"dx":10,"dy":4}}} @@ -9823,9 +9898,10 @@ same in any language. Saved documents keep what they say, and imported text (PDF Type on a path flows between a start and an end bracket, stored as fractions of its path's length. `type.pathOptions {start?, end?, flip?, effect?, alignToPath?, spacing?, ids?}` sets them and the rest of Type on a -Path Options: `end: null` puts the end bracket back at the end of the path (or once round a closed one); `flip` -then turns the type to the other side of its path, the path running the other way and the brackets swapping ends so -the type keeps its stretch of the path; `alignToPath` runs the `ascender`, `descender`, `center` or `baseline` (the +Path Options: `end: null` puts the end bracket back at the end of the path (or once round a closed one); on an open +path a `start` below 0 starts the text that far before the path's start (on a closed path it is the path's start); +`flip` then turns the type to the other side of its path, the path running the other way and the brackets swapping +ends so the type keeps its stretch of the path; `alignToPath` runs the `ascender`, `descender`, `center` or `baseline` (the default) along the path, each character by its own font, size and vertical scale: `ascender` runs the top of the font's em box along it (its ideographic em box, else an em box centered on the cap height), `descender` runs the bottom of the font's bounding box along it, and `center` runs a line at half the height of the top of the font's H @@ -9838,6 +9914,13 @@ show selected type on a path's brackets: dragging the start or end bracket (`poi begins or ends, dragging the centre bracket slides the type along its path and, dragged across the path, flips it (with Cmd/Ctrl held it only slides). Each is one undo step. +Type on an open path that starts before the path's start keeps every character, and the characters whose middle falls +before the path's start are not drawn. A `flip` starts it at the start bracket, and dragging the center bracket slides +it from where it starts and no further before the path. Past the path's end, text that does not fit is overflow, as +for other type on a path. SVG import reads a `` anchored or offset before an open path's start this way, and +SVG export writes it as a negative `startOffset`. Releasing it to point type keeps the first drawn character where it +was drawn. + ```json {"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}} ``` @@ -12202,10 +12285,11 @@ VectorCraft addition and changes no parity score. the path's fill and stroke), so the released path has no fill and no stroke. - **The text** keeps its runs with their character attributes and its paragraph attributes, and becomes point type on one straight line along the object's own axis, with the baseline starting where the first character sat on the path - (type with no characters starts at its start bracket). 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`) becomes point - type with it off, at the sizes it was drawn at: each size, leading and baseline shift is multiplied by the fit factor. - Without `keepOriginal` the type object itself becomes the point + (type with no characters starts at its start bracket; type that starts before an open path's start keeps its first + drawn character where it was drawn, with the characters that were not drawn 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`) becomes point type with it off, at the sizes it was drawn at: each size, leading and + baseline shift is multiplied by the fit factor. Without `keepOriginal` the type object itself becomes the point type, keeping its id, so a frame of threaded text leaves its thread with the text it holds, as `type.convertToPointType` does for area type. - It acts on the type on a path among `ids` (or `id`, else the selection), groups included; other objects are left @@ -12247,6 +12331,7 @@ the error text. The interactive fallback applies only to ordinary File > Save, n | Date | Change | |---|---| +| 2026-11-14 | Built-in Tech Circuit brush library (id `tech-circuit`): Circuit Trace, Bus Lines and Terminal Nodes; ten built-in libraries. | | 2026-11-13 | The Pencil ignores Smart Guides and Snap to Point and shows no Smart Guides; Snap to Grid and Snap to Pixel still move its ends (#7252). | | 2026-11-13 | `path.freehand`: `closed`, when given, decides whether a stroke that continues a path (`extend`) closes it; the Pencil and the Paintbrush send it by Close Paths When Ends Are Within. | | 2026-11-13 | Snap to Point with Smart Guides off draws no Smart Guides labels or lines (#7250). | diff --git a/docs/roadmap.md b/docs/roadmap.md index 1f89391864..fbe7ccc0fb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; 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: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -165,7 +165,7 @@ Gujarati interface follow-up (2026-11-14): the catalog and zero/one plural rule | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, six generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons, Weather Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, nine generated libraries including Ink and Wash, Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, six generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons, Weather Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, ten generated libraries including Tech Circuit, Ink and Wash, Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369); Live Paint Bucket fills faces crossed in one drag (#6373) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | @@ -207,6 +207,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 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-12 | minor | Gujarati (`gu`): 4,286-row machine-assisted catalog reviewed by a native speaker, zero/one singular plurals, desktop font fallback and optional Gujarati craft-fonts fallback; all 72 translation tests pass; a web font and about 2,600 untranslated messages remain; scores and estimates unchanged | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index db2a0fcea8..1639b70a3b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2,6 +2,8 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Pencil ignores Smart Guides and Snap to Point and shows no Smart Guides, #7252; previously: Pencil continuation, straight-line and closing cursors; scores and estimates unchanged; previously: G5: Weather Icons joins built-in symbol libraries (Sun, Moon, Cloud, Rain, Thunder, Snow); scores and estimates unchanged; previously: a closed Pencil stroke closes with a straight segment; previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; previously: 3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously: `.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > +> Tech Circuit update (2026-11-14): three original procedural brushes; ten built-in brush libraries (G5). +> > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > > Glyphs update (#5514): Alternates for Current Selection applies a variant's OpenType feature to that letter alone (`salt=2`) so an Arabic word stays joined; Entire Font lists unencoded glyphs. A positional variant no supported feature draws can be applied as a glyph choice (#6084). Entire Font uses the selection's font version and variable axes, and the default cell remains available after an unlisted choice even without GSUB alternates. Only Arabic presentation-form characters insert their joining base letter. Scores and estimates unchanged. @@ -60,6 +62,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| | 2026-11-14 | minor | G5: Weather Icons joins Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons as a sixth built-in symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow; original procedural vector symbols generated in code, no default 9-slice); area score and estimate unchanged. | +| 2026-11-14 | minor | G5: 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 | Pencil and Paintbrush: a closed stroke (Alt at the release, or ending within Close Paths When Ends Are Within of its start) is fitted as the open stroke would be and closed by a straight segment back to its start, instead of curving on past both ends (`pencil_alt_closes_a_stroke_straight_back_to_its_start` in `crates/engine/src/tests/draw2.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Pencil cursors: the continuation cursor (a short rising line) shows over an open end of a selected path before any press, freehand too, and while a continuing stroke nears another selected path's open end it connects to; the straight-line cursor is a short level line; a continued path, a straight line too, shows the closing cursor and closes within Close Paths When Ends Are Within of its other end, and with Alt at the release (`a_continued_stroke_shows_where_it_connects_or_closes` in `crates/tools/src/draw2/gesture/tests.rs`, `pencil_extend_closes_as_closed_says` in `crates/engine/src/tests/draw2.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Pencil: ignores Smart Guides and Snap to Point and shows no Smart Guides, hovering or drawing; a stroke's ends and both ends of its straight lines follow the hand, while Snap to Grid and Snap to Pixel still move them (#7252; `pencil_follows_the_hand_and_shows_no_smart_guides`, `pencil_ends_still_snap_to_the_grid_and_pixels` in `crates/tools/src/draw2/gesture/tests.rs`). Public basis: community experts on the reference app's forum say Smart Guides and the other snapping options don't make the Pencil snap ([Drawing and snapping to path in Illustrator](https://community.adobe.com/questions-652/drawing-and-snapping-to-path-in-illustrator-766887)). Scores and estimates unchanged. | @@ -239,7 +242,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged; the Pen, Smart Pen and Curvature tool show the measurement readout with the rubber band off (#7043), score, gaps and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522); Live Paint Bucket face drags, including narrow faces and one Undo, regression-tested (#6373) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged; a point on a gradient mesh's outline locates on its edge on every side (#7006: `mesh_locate_takes_a_point_on_any_side_of_the_outline`, `mesh_add_line_exactly_on_the_edge_needs_no_tolerance`), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (nine generated libraries including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (ten generated libraries including Tech Circuit, Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | @@ -508,7 +511,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (nine generated in code including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (ten generated in code including Tech Circuit, Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -560,7 +563,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have six (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons, Weather Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries nine (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have six (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons, Weather Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries ten (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media, Tech Circuit) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -583,6 +586,7 @@ since 2026-10-01. - **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged. - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. +- 3D lighting widget: the Lighting tab of the 3D and Materials panel shows every light on a sphere at its Rotation and Height; a drag moves a light in one undo step, a press selects it, Move Light to Back and Move Light to Front change its side, and the arrow keys move the focused light ([three-d.md](three-d.md#lighting-widget)). `crates/ui-egui/src/panels/tests_three_d.rs` covers the drag, selection, side and keyboard. - 3D shadows: Revolve, Extrude & Bevel, Inflate and Rotate take a Shadows switch with Position (Behind Object or Below Object), Distance from Object (0–100 %) and Shadow Bounds (10–400 %) on the Lighting tab ([three-d.md](three-d.md#shadows)); the first light casts one hard-edged vector shadow, drawn behind the solids, in exports and kept by Expand Appearance. Evidence: `crates/three-d/src/shadow/tests.rs`, `crates/effects/src/solids/tests_shadow.rs`, `effectcmd::tests::a_3d_shadow_is_one_undo_step_saved_exported_and_expanded`. Softness and the shadows of lights after the first are not built. Scores unchanged. - 3D shadows: with Shadows on, Revolve, Extrude & Bevel, Inflate and Rotate cast the shadow from the faces kept by the build that draws each solid, so drawing an object builds each of its solids once. The art and the shadow are unchanged. Evidence: `crates/three-d/src/shadow/tests.rs` (`a_solid_and_its_shadow_are_built_once` and the tests that compare each solid and its shadow with the two built apart), `solids::tests_shadow::a_shadowed_3d_object_builds_each_solid_once`, `solids::tests_shadow::the_shadow_cast_from_the_solids_drawn_is_the_shadow_of_solids_built_for_it`, `revolve::tests::the_shadow_cast_from_the_surfaces_drawn_is_the_shadow_of_surfaces_swept_for_it`. Scores unchanged. - Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. @@ -744,6 +748,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. - **Document Setup › Color:** a Color tab shows the document color mode (RGB Color or CMYK Color), a Convert to CMYK or Convert to RGB box for a mode change (cleared: the colors keep their numbers), and the RGB and CMYK profiles assigned to the document, with a working-space entry for none. OK runs `edit.assignProfile` and then `file.documentColorMode` with the dialog's other changes in one `command.batch`, and the document's changes are one undo step. An unchanged Color tab adds no color commands, and Cancel, or OK with nothing changed, records no undo step. A Color tab field with a value of the wrong kind is an error, and the dialog stays open. The tab is VectorCraft's own layout; File › Document Color Mode and Edit › Assign Profile… stay as they are. Tests in `dialogs/document_setup.rs`. +- **Assign Profile's three choices:** the Assign rows of Edit › Assign Profile… (the Separations panel's Color Settings section) offer Don't Color Manage This Document, the working space of the document's color mode (Working RGB or Working CMYK, with its name) and Profile with the other profiles of that kind, as [Assign or remove a color profile](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) lists them. They act on the profile of the document's color mode: an RGB document's RGB profile, a CMYK document's CMYK profile; the other model's profile stays as it is (VectorCraft's own rule; Document Setup › Color sets both). Picking the working space assigns its profile, which stays when the working space changes later. A choice the document already has runs nothing. Tests in `panels/separations.rs`. - **Canvas through the display's profile on Windows and X11:** Preferences › Performance › Use Monitor Profile for Canvas (on by default) converts the canvas's art, artboard paper and transparency grid from sRGB into the ICC profile of the display that shows most of the main window, after any proof, as the help page describes the monitor's profile translating the document's colors for the monitor ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). Windows gives the display's default profile, and a display with Advanced Color on is left to Windows ([Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/wcs/advanced-color-icc-profiles)); X11 gives colord's profile for the output or the `_ICC_PROFILE` property. macOS converts the window itself, and a Wayland session stays sRGB. `ui.inspect` reports `displayProfile`. Tests in `color/src/cms/display.rs`, `ui-egui/src/display_profile.rs` and `canvas::tests::the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on`. - **Translucent art blended before the display profile:** with Use Monitor Profile for Canvas converting, translucent art and the anti-aliased edges of art over an artboard's paper, a colored artboard or the transparency grid are blended with it in sRGB and the blend is converted, so the monitor's profile translates the document's colors as they are composited ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). The frame rendered whole, the part an edit renders again, the render worker's frame, the pan fill and the other windows' panes do the same; over the pasteboard and in Outline the art is converted on its own, and with no profile to convert into nothing changes. Tests `display_profile::tests::translucent_pixels_over_the_paper_are_blended_before_they_are_converted`, `the_transparency_grid_is_blended_cell_by_cell`, `the_backdrop_is_found_through_the_view` and `canvas::tests::translucent_art_over_the_paper_is_blended_before_the_display_profile`. - **Out-of-gamut warning after a profile change:** the out-of-gamut warning and its closest-color correction in the Color panel and Color Picker ([the help page](https://helpx.adobe.com/illustrator/using/adjusting-colors.html)) check the color against the document's assigned CMYK profile, else the working CMYK space, and update when Edit › Assign Profile… or Edit › Color Settings… changes that space or a loaded profile changes, without a change of color. Which CMYK space the warning uses is VectorCraft's own rule. Tests: `gamut_warning_follows_a_profile_change` and `gamut_warning_follows_a_profile_loaded_again` in `dialogs/color_picker/tests.rs`. @@ -755,6 +760,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Repeated brush names in a file:** a document or brush library file whose brushes repeat a name opens with the first brush of that name as stored and each later one numbered, as "Name 2", "Name 3" and so on, skipping names the file already has. Strokes that name the brush keep drawing the first one, and `brush.list`, `brush.options`, `brush.delete`, `brush.library` and the Brushes panel then reach each brush by its own name. This numbering is VectorCraft's own rule. Tests in `crates/brush/src/tests.rs`, `tests/brushsym.rs` and `tests/brushlib.rs` cover it. - **Pattern names and swatch names:** `pattern.options` refuses a new name that another pattern, a swatch or a color group has. It takes a name that only the pattern's own swatches have. The reference page does not say what happens when the new name is taken; the refusal follows VectorCraft's rule that swatches and color groups share one namespace (`Document::swatch_name_taken`), which `swatch.new` and `swatch.edit` also follow. A rename gives the new name only to the pattern's swatches that had the old one; any other swatch with the old name keeps it, and art linked to that swatch stays linked. `object.pattern.make` and `object.pattern.saveCopy` number a color group's name as they number a swatch's. Tests in `tests/pattern.rs` cover each command. Public reference: [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copy). - **Cancel, Delete and rename leave no paint of a missing pattern:** the help page lists Save a Copy, Done and Cancel for leaving pattern editing (https://helpx.adobe.com/illustrator/using/create-edit-patterns.html). Canceling a pattern made for the edit removes it with every swatch of it, a color group's included. Canceling a new pattern, `pattern.delete` and a `pattern.options` rename reach every paint of the pattern: art outside the editing layer, opacity mask art, type runs, symbol definitions, graph designs, the series and label paints a graph keeps for its next redraw, other patterns' tiles (a copy Save a Copy made included), graphic and character styles, brush art, and the fill, stroke and appearance new art takes. Canceling a new pattern and Delete make those paints no paint, a VectorCraft rule; a rename renames them. Canceling the edit of an existing pattern puts its definition back as it was when editing started; after a rename in Pattern Options, it also gives the pattern its old name back with its swatches and paints, unless another pattern, swatch or color group has taken that name. Undo and Redo restore new art's paints with the document. Tests in `tests/pattern.rs` cover each command. +- **Move Tile with Art:** Pattern Options has the Move Tile with Art checkbox under Size Tile to Art, and with it on, moving the tile art in pattern editing mode moves the tile with it (). VectorCraft's own rules for what the page leaves open: the tile moves only when a move takes all of the tile art, by the same offset; rotations, scales, copies and anchor moves leave it in place; the option is off for a new pattern. `pattern.options {moveTileWithArt}` sets it and `pattern.list` reports it. Tests in `tests/pattern.rs` and `panels::pattern_options::tests` cover it. - **A clip group in Properties:** with one clip group selected, the panel has Edit Clipping Path and Edit Contents at its top, under the Clip Group heading, which select the clipping path or the clipped art for the selection tools to transform alone, as Object › Clipping Mask does (: with a clip group selected, the tutorial uses Edit Clipping Path at the top of the Properties panel to select the mask for editing, and uses Edit Contents to select the masked art). Edit Contents shows only when the group clips something. Not checked against the reference app: whether Properties keeps the buttons while the clipping path or the contents is selected; the Control bar has no clip-group buttons here. Evidence: `panels::properties::tests::one_clip_group_has_edit_clipping_path_and_edit_contents_at_the_top`. - **Contextual controls in Properties:** the [Properties panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html) lists controls that appear for particular selections and tools; Properties has them, each running the existing command. Quick Actions has Blend Options for one selected blend, with the Blend tool or any other, as the [blending page](https://helpx.adobe.com/illustrator/using/blending-objects.html) describes (#4817) (the Blend Options dialog, OK one undo step) and Merge Live Paint when a Live Paint group is selected with more paths and nothing Live Paint refuses (`livePaint.merge`). Appearance has the stroke's Variable Width Profile list, the Control bar's, with the Width tool or when the stroke has a variable width (`stroke.set {profile}`), and beside fx the Delete icon: with one effect on the one selected object (its own or a fill's or stroke's) its name and an icon that removes it (`effect.remove`), with several an icon that opens the Appearance panel, as the overview describes. With Direct Selection and corners of one path selected, Corner and Corner Radius set the kind and radius of those corners (`object.setLiveShape`); a live rectangle's and polygon's own rows already do. Each edit is one undo step. The overview's Variable Font icon was already in Character. Not checked against the reference app: whether Merge needs two Live Paint groups (the overview speaks of several selected groups; `livePaint.merge` takes a group and paths), and where exactly each control sits in its section. Evidence: `panels::properties::tests::{blend_options_is_a_quick_action_for_one_blend_with_the_blend_tool, merge_live_paint_is_a_quick_action_for_a_live_paint_group_and_more, the_width_profile_shows_for_a_variable_width_or_the_width_tool, direct_selected_corners_show_their_kind_and_radius, one_effect_has_a_delete_icon_in_appearance}`. - **Edit Artboards in Properties:** a Transform section comes first (#5219): the reference point the Transform panel and Control bar share, the active artboard's X and Y (of that point) and W and H with the Constrain Width and Height Proportions link, its preset and orientation, and Move and Scale Artwork with Artboard. The Artboards section follows, with the name field, Artboard Options and Fill. The active artboard's name is a field. Typing a new name and pressing Enter or clicking elsewhere renames that artboard in one undo step, as the Artboards panel's rename does (`artboard.setProps {index, name}`); pressing Escape or leaving the field empty keeps the old name. A click on the Artboard Options icon beside the field opens Artboard Options for the active artboard, as double-clicking the Artboard tool's button does (`tool.options {tool: "artboard"}`). Pointer and key tests in `panels/properties.rs` cover both. Public references: [Resize artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/resize-artboards.html) and [Create and add new artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/add-new-artboards.html). @@ -995,8 +1001,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Ink and Wash adds ink and wash brushes (the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) lists an Ink brush library of strokes with varied thickness and texture; the names, shapes and opacities are VectorCraft's own): Tapered Ink, an art brush broad near its start that thins to a fine tip; Brush Pen, a calligraphic brush whose size follows the pen pressure; and Soft Wash and Pooling Wash, art brushes of translucent gray layers that take the stroke color as tints; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Dry Media adds grainy art brushes (Chalk Grain, Graphite Pencil, Wax Crayon, Charcoal Dust). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media, Tech Circuit) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Ink and Wash adds ink and wash brushes (the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) lists an Ink brush library of strokes with varied thickness and texture; the names, shapes and opacities are VectorCraft's own): Tapered Ink, an art brush broad near its start that thins to a fine tip; Brush Pen, a calligraphic brush whose size follows the pen pressure; and Soft Wash and Pooling Wash, art brushes of translucent gray layers that take the stroke color as tints; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Dry Media adds grainy art brushes (Chalk Grain, Graphite Pencil, Wax Crayon, Charcoal Dust). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - **Bristle Brushes library** ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html), [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Bristle Brushes holds ten bristle brushes, one for each tip shape (Fine Liner, Pointed Flat, Stubby Round, Dry Scrub, Soft Dome, Filbert, Angled Round, Angled Flat, Fan Blender, Grass Fan). The brushes, their names and their values are VectorCraft's own, and every value lies inside the range the bristle brush keeps (`brush.libraries`, `brush.addFromLibrary`; `the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges`). + - **Tech Circuit library:** Circuit Trace is a chamfered art brush with 45-degree elbows and terminal pads; Bus Lines repeats three parallel conductor lanes; Terminal Nodes repeats solder pads, circular test points and connecting leads. Original vector geometry generated in code, with Tints colorization and continuous pattern tile edges. Evidence: `the_tech_circuit_library_constructs_adds_and_applies_each_brush` in `crates/engine/src/tests/brushlib.rs` covers listing, finite geometry on straight, curved, short and closed paths, add/apply, undo/redo, deduplication and saved-library round trips. G5 remaining estimate 3–13 h (estimated); area scores unchanged. - **Dry Media library** ([help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Dry Media holds four art brushes with textured strokes like dry drawing materials, each 100 pt long: Chalk Grain (about 8 pt high), Graphite Pencil (about 2.3 pt), Wax Crayon (about 5.5 pt) and Charcoal Dust (about 7 pt). The brushes, their names, sizes and art are VectorCraft's own: each is filled vector paths from a fixed seed, at most 80 paths and 500 anchors, stretched along the path with Tints colorization, and the chalk and crayon art has even-odd gaps where the paper shows (`brush.libraries`, `brush.addFromLibrary`; `the_dry_media_library_holds_grainy_art_brushes_with_bounded_art`). - **Brush names in library panels**: a built-in brush library's panel shows its brushes' names in the UI language, in its list rows and tooltips, and its Find field matches the name shown or the English name. This is VectorCraft's own rule. A User Defined or Other Library… library's brushes, and the document's Brushes panel, show names as they are stored. A click, a drag, Add to Brushes and `brush.addFromLibrary` still use the English name, so the brush comes into the document under that name (`built_in_library_brushes_are_listed_in_the_ui_language`, `built_in_library_brush_names_are_in_complete_catalogs`). - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. @@ -1141,6 +1148,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Alt-drag with the Knife cuts in a straight line, and Shift+Alt-drag cuts at 45° or 90°, as [Cut objects using the Knife and Scissors tools](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) describe. Alt counts only when held at the press; the dashed line runs from the press point to the pointer, stays straight after Alt is let go, and release sends `path.knife` with those two points. Alt pressed after the press leaves the cut freehand. Shift+Alt keeps the endpoint at 45° steps from the horizontal and vertical, whatever the Constrain Angle. With Smart Guides on, a straight Knife cut snaps to anchor points and paths: [Cut objects using the Knife and Scissors tools](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) describes the Knife snapping to anchor points and paths while Smart Guides are on. Its ends snap as the ends of the Pencil's straight line do, to anchor points and path segments of art that is shown: the press, and the release of an Alt-drag. Shift+Alt steps 45° from the snapped press, and its release stays on that step. With Smart Guides off, or with Snap to Pixel or Snap to Grid on, the cut is not snapped. The dashed line and `path.knife` use the snapped points. Eraser Tool Options has Angle and Roundness besides Size, as [Erase paths using the Eraser tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html) describes: the Eraser's tip is an ellipse Size across, its long axis at Angle and its short axis Roundness percent of the long one, and a drag with a tip that is not round shows the outline of the area it erases (`path.eraseRegion` `angle` and `roundness`). + Eraser Tool Options gives Angle, Roundness and Size each a menu of Fixed, Random, Pressure, Tilt, Bearing and Rotation with a Variation field, as the same page describes: the tip varies by up to the Variation either way, Random once per drag and the others with what the pen reads at each point of the drag, and the tip outline at the pointer follows the pen while it erases. The outline of the area a drag shows keeps the set Angle and Roundness, and a mouse drag erases at the set values (`path.eraseRegion` `angleMode`, `roundnessMode`, `sizeMode` and their variations). The outline a Blob Brush or Eraser drag shows is built a bounded amount per frame, so a long stroke does not slow the canvas: each frame takes at most 256 new points of the drag and outlines again only the pieces of up to 32 segments they change, and the parts of a piece's outline inside the rest of the area are removed a limited amount at a time. While work is left, the canvas draws again with the pointer still, the points not outlined yet show as a thin line, and the tip shows at the pointer. With Size set to Pressure the outline also shows only the edge of the whole area. The release sends the whole stroke as before (`path.blob`, `path.eraseRegion`). The settled part of that outline is kept in paths of up to 64 pieces each. A path is shared from one frame to the next until a piece in it changes, and the canvas keeps the triangles it drew the path with while the view stays the same, so a frame does not copy, flatten or tessellate a settled path again; a pan, zoom or rotation draws each one once more. This is VectorCraft's own rule. The outline of a drag with a round Eraser tip is drawn whole each frame. - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). @@ -1177,6 +1185,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur What Global Edit gives a mark from another match (in an isolated graph) is kept on the graph the same way, and so is the object opacity, with its blend mode, that Randomize Opacity and Sort Objects change on marks. - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. + The transparency, paint, stroke, gradient and overprint commands and Expand Appearance also leave the art of a column or marker design inside a series unchanged. The graph draws that art again from the design (`docs/mcp.md`). - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. While a drag rotates the view, a dial at the center of the canvas shows the angle. - **Editing type:** double-clicking type with a selection tool (Selection or Direct Selection) switches to the Type tool with the caret where it was clicked; pressing Escape finishes editing and switches back to the selection tool used before double-clicking it (when in isolation mode, the first Escape exits text editing while preserving isolation mode, and a second Escape exits isolation). @@ -1294,6 +1303,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5° (Preferences › General › Trackpad Gesture to Rotate View, on by default). On a touch screen a second finger cancels the tool press the first finger began, in the frame it lands or later: the Pencil's stroke or a Selection move is rolled back with no undo entry, and a finger that lands while another is down presses nothing, so a pinch or two-finger pan only zooms and pans (#4198; canvas test `a_second_finger_cancels_the_tool_gesture_the_first_began`). In the Touch workspace a two-finger tap switches between the Selection tool and the drawing tool chosen last (the Pencil tool when none has been chosen yet) and undoes nothing; outside Touch it still undoes, and a three-finger tap still redoes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/touch-workspace-in-illustrator.html); `tool.toggleTouchMode`; canvas test `two_finger_tap_in_touch_switches_selection_and_drawing_modes`). + When the tool cannot be chosen, `tool.toggleTouchMode` returns the error instead of `{tool}`, keeps the drawing tool it returns to, and adds no undo step; `tool.select`, `ui.cropImage` and the Path Precision Tools' Corner Tool and Shared Edge Merge Tool commands also return the error, and the toolbar group keeps its tool (canvas tests `a_failed_mode_switch_returns_its_error` and `tool_commands_return_a_failed_choice`). While a modal dialog is open, from the frame it opens, the canvas takes no new presses, double-clicks, finger taps, long touches, pinches or wheel turns, and a click in Presentation Mode does not step to the next artboard: a two-finger tap undoes nothing, a three-finger tap redoes nothing, a two-finger tap in the Touch workspace switches no mode, and fingers already down when the dialog opens make no tap when they lift, during the dialog or after it closes. This is VectorCraft's own rule (canvas tests `modal_blocks_two_and_three_finger_canvas_taps`, `opening_a_modal_discards_a_pending_canvas_tap`, `modal_blocks_presentation_tap_and_touch_navigation`, `modal_long_touch_does_not_cancel_canvas_press` and `modal_blocks_new_canvas_presses`). A tool press dropped by a second finger, a long touch or a canceled touch cancels the tool's drag as Escape does: the tool gets back the options it had when the press began (the Polygon tool's sides, set with ↑ and ↓) and they are not stored, a press held still opens no size dialog, a Selection or Artboard tool marquee selects nothing, and the Pen goes on drawing its path without the dropped anchor (canvas tests `a_canceled_touch_drops_the_drag_without_keeping_its_options` and `long_touch_drops_a_tool_press_without_committing_art`; engine tests in `tests::toolsettings`). The status bar navigator's field shows the active artboard's number and name, and its drop-down lists every artboard by number and name, as the Artboards panel does. Choosing an artboard in the list makes it the active artboard and fits it in the window (`view.goToArtboard`). The list opens on the active artboard's row and lays out only the rows in view and at most one more. @@ -1455,7 +1465,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Paragraph panel's Mojikumi Set lists the five predefined sets the help page names (None, YakumonoHankaku, GyomatsuYakumonoHankaku, GyomatsuYakumonoZenkaku and YakumonoZenkaku), then Line-end Punctuation Half Width, and `text.setFormat {mojikumi}` takes each of them. YakumonoHankaku sets every bracket, comma and full stop half width and takes a quarter em off each side of a middle dot; GyomatsuYakumonoHankaku keeps punctuation full width inside a line, sets an opening bracket flush at a line's start and a closing mark half width at its end, and takes the quarter em after a middle dot off at a line's end; GyomatsuYakumonoZenkaku keeps punctuation full width and adds a quarter em between Japanese and Latin text; YakumonoZenkaku sets characters as None does. These widths are VectorCraft's own rules, from the help page's one-line description of each set. Both composers and vertical type measure each line as it is set, the sets save with the document and paragraph styles, and a `.ai` save reports the new sets as left out (`crates/text/src/tests/vertical.rs`, `crates/ui-egui/src/panels/paragraph/tests.rs`). Scores and estimates are unchanged. Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. - The Glyphs panel menu (with the East Asian options) offers Traditional, Expert, JIS 04, JIS 90, JIS 78 and JIS 83 Forms, Monospaced Full-Width, Half-Width, Third-Width and Quarter-Width Forms, and Revert To Default Forms ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); each draws the selected characters with the font's OpenType feature for that form (`trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, `fwid`, `hwid`, `twid`, `qwid`), one form at a time, and a form none of the faces that draw the selected text has a feature for is disabled. `text.setFormat` and `text.setRangeStyle {glyphForm}` set it; the native file, character styles, the canvas, PDF and SVG (`font-feature-settings`) keep it (`tests::typo::glyph_forms_draw_the_fonts_east_asian_forms`, `typecmd::area_tests::glyph_form_is_one_character_attribute_saved_only_when_set`, `panels::glyphs` tests). + The Glyphs panel menu (with the East Asian options) offers Traditional, Expert, JIS 04, JIS 90, JIS 78 and JIS 83 Forms, Monospaced Full-Width, Half-Width, Third-Width and Quarter-Width Forms, and Revert To Default Forms ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); each draws the selected characters with the font's OpenType feature for that form (`trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, `fwid`, `hwid`, `twid`, `qwid`), one form at a time, and a form none of the faces that draw the selected text has a feature for (in `GSUB`, or for the width forms `GSUB` or `GPOS`) is disabled. A form other than the default takes away a character's glyph choice, and with a width form Proportional Metrics' `palt` is not applied and the font's pair kerning is off. `text.setFormat` and `text.setRangeStyle {glyphForm}` set it; the native file, character styles, the canvas, PDF and SVG (`font-feature-settings`) keep it (`tests::typo::glyph_forms_draw_the_fonts_east_asian_forms`, `typecmd::area_tests::glyph_form_is_one_character_attribute_saved_only_when_set`, `typecmd::area_tests::a_glyph_form_takes_away_the_characters_glyph_choice`, `features::tests::width_forms_turn_off_proportional_metrics`, `panels::glyphs` tests). Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, on a vertical path it moves toward the path's start and to the path's left, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved. Character panel › Insert Aki Left and Insert Aki Right (with the East Asian options; `text.setFormat` or `text.setRangeStyle {akiLeft, akiRight}`, thousandths of an em from 0 to 1000, 0 by default) add space before and after each selected character, measured as tracking is, and kept as set through justification ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); before and after follow the reading order in vertical and right-to-left type, character styles carry both values (Character Style Options › Advanced Character Formats edits them), and a file holding a value outside the range opens with the nearer end. @@ -1735,7 +1745,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `