From b563976b4d8db6be40f11268cababeb60ece8b00 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:13:48 +0430 Subject: [PATCH 01/79] Text: OpenType feature values (salt=2), a GSUB reader and runs that differ only in features shaped as one piece Features take a value (tag=N) in style commands, SVG and native files. Runs that differ only in their feature lists are shaped in one buffer with ranged features, so an alternate on one letter of an Arabic word keeps the word joined. FontFace reads GSUB once (alternate sets, singles, the feature reaching a glyph), names unencoded glyphs, and alternates_in_context finds a character's alternates in its word. text.setRangeStyle takes glyphAlternate: null. --- crates/doc/src/text/char_style.rs | 4 +- crates/engine/src/cmd/textedit.rs | 25 +- crates/engine/src/cmd/textstyles/tests.rs | 6 +- crates/engine/src/cmd/typecmd.rs | 2 +- crates/engine/src/tests/textedit.rs | 83 +++++ crates/svg/src/css.rs | 11 +- crates/svg/tests/it/text_layout.rs | 13 + crates/text/src/context_alternates.rs | 147 +++++++++ crates/text/src/features.rs | 208 ++++++++++-- crates/text/src/fontdb.rs | 3 + crates/text/src/fontdb/alternates.rs | 156 +++------ crates/text/src/fontdb/build.rs | 2 + crates/text/src/fontdb/face.rs | 5 + crates/text/src/fontdb/gsub.rs | 298 ++++++++++++++++++ crates/text/src/lib.rs | 12 +- crates/text/src/shape.rs | 56 +++- crates/text/src/test_fonts.rs | 84 +++++ crates/text/src/tests.rs | 1 + crates/text/src/tests/glyph_alternates.rs | 293 +++++++++++++++++ .../ui-egui/src/dialogs/char_style_options.rs | 12 +- crates/ui-egui/src/panels/opentype.rs | 24 +- docs/control-protocol.md | 20 +- docs/mcp.md | 36 ++- 23 files changed, 1317 insertions(+), 184 deletions(-) create mode 100644 crates/text/src/context_alternates.rs create mode 100644 crates/text/src/fontdb/gsub.rs create mode 100644 crates/text/src/tests/glyph_alternates.rs diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 67ccb60a44..6ad201561d 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -51,7 +51,9 @@ pub struct CharStyle { #[serde(default)] pub all_caps: bool, /// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns - /// a feature on, `"-liga"` off. + /// a feature on, `"-liga"` off, and `"salt=2"` turns a feature with alternates on at the font's + /// alternate number 2 (the value the shaper gets; a plain `"salt"` is value 1). Runs that + /// differ only in this list are shaped together, each with its own features. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub features: Vec, /// Character style (Character Styles panel) these attributes come from; None = Normal. diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index fba84acafe..6aa546767b 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, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, 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\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; 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; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), 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), 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)} 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, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, 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; 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; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), 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), 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 ), @@ -464,6 +464,8 @@ pub(crate) struct CharChange { language: Option>, font_axes: Option, font_height_reference: Option, + /// Take away the glyph choice ([`CharStyle::glyph_alternate`]) of the characters (`glyphAlternate: null`). + clear_glyph_alternate: bool, } /// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None` @@ -565,7 +567,8 @@ pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) { } } -/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults). +/// `features: ["dlig", "-liga", "salt=2", …]` → the canonical tag list (differences from the +/// defaults; `salt=1` is written `salt`, `salt=0` and `-salt` leave it out). pub(crate) fn features_param(p: &Value, cmd: &str) -> Result>> { let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) }; let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect(); @@ -573,7 +576,8 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result> return Err(bad( cmd, format!( - "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom; prefix - to turn off)" + "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {}, as `salt=2`)", + vectorcraft_text::MAX_FEATURE_VALUE ), )); } @@ -583,9 +587,9 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result> /// The most entries a `featureChanges` list may have. const MAX_FEATURE_CHANGES: usize = 256; -/// A feature tag without its `-` or `+` prefix. +/// A feature tag without its `-` or `+` prefix and its `=N` value. fn feature_name(tag: &str) -> &str { - tag.strip_prefix(['-', '+']).unwrap_or(tag) + vectorcraft_text::OtFeatures::tag_name(tag) } /// `featureChanges: ["tnum", "-onum", …]` → the features to turn on (or, with `-`, off) on top of @@ -752,6 +756,13 @@ impl CharChange { language: language_param(p, cmd)?, font_axes: font_axes_param(p, cmd)?, font_height_reference: font_height_reference_param(p, cmd)?, + clear_glyph_alternate: match p.get("glyphAlternate") { + None => false, + Some(Value::Null) => true, + Some(_) => { + return Err(bad(cmd, "`glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one)")); + } + }, }; if c.size.is_some_and(|v| v <= 0.0) { return Err(bad(cmd, "size must be positive")); @@ -804,6 +815,7 @@ impl CharChange { && self.language.is_none() && self.font_axes.is_none() && self.font_height_reference.is_none() + && !self.clear_glyph_alternate } pub(crate) fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> { @@ -943,6 +955,9 @@ impl CharChange { if let Some(v) = &self.language { st.language.clone_from(v); } + if self.clear_glyph_alternate { + st.glyph_alternate = None; + } } } diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 7c0fd99638..821bd7d999 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -309,12 +309,12 @@ fn character_style_color_and_features_reach_text_and_follow_swatch_edits() { // The command takes the color of the link at the tint, whatever color is sent. tinted["tint"] = json!(0.4); let attrs = json!({"fill": brand, "stroke": tinted, "stroke_width": 2.0, "overprint_fill": true, "overprint_stroke": false, - "features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]}); + "features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01", "salt=2"]}); s.execute("charStyle.new", &json!({"name": "Ink", "attrs": attrs})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Ink", "id": id})).unwrap(); let want_stroke = s.doc().unwrap().doc.tint_paint("Brand", 0.4).unwrap(); assert_eq!(run_paints(&s, id), (brand, want_stroke.clone(), 2.0, true, false)); - assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]); + assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01", "salt=2"]); let def = |s: &Session| s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Ink").unwrap().attrs.clone(); assert_eq!(def(&s)["stroke"], serde_json::to_value(&want_stroke).unwrap()); let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); @@ -355,6 +355,8 @@ fn character_style_color_and_features_are_checked() { (json!({"stroke_width": 5.0e6}), "`stroke_width` must be from 0 to 4000000"), (json!({"features": ["dlig", "zzzz"]}), "unknown OpenType feature `zzzz`"), (json!({"features": ["--liga"]}), "unknown OpenType feature `--liga`"), + (json!({"features": ["salt=x"]}), "unknown OpenType feature `salt=x`"), + (json!({"features": ["-salt=2"]}), "unknown OpenType feature `-salt=2`"), (json!({"features": many}), "features lists at most 256 tags"), (json!({"fill": {"type": "solid"}}), "missing field"), ] { diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 519d282e84..c0706fbb2c 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -123,7 +123,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", …] OpenType, featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (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), 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), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), 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_style ), diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index ed3e2eac39..3578a67dbd 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -139,6 +139,54 @@ fn feature_changes_keep_each_runs_other_features() { assert_eq!(features(&s), [vec!["smcp", "numr", "liga", "dnom"], vec!["swsh", "numr", "liga", "dnom"], vec!["-onum", "swsh", "dnom"]]); } +/// A feature with a value (`salt=2`): `features` and `featureChanges` take it, the next value +/// replaces it, `salt=1` is a plain `salt` and `salt=0` and `-salt` are off, a value this build +/// does not know is an error, one undo step each, and the native format keeps the plain string. +#[test] +fn feature_values_are_set_replaced_and_saved() { + let mut s = session(); + let id = text(&mut s, "abcd"); + let features = |s: &Session| obj(s, id).runs.iter().map(|r| (r.text.clone(), r.style.features.clone())).collect::>(); + let range = |s: &mut Session, p: Value| { + let mut p = p; + p["id"] = json!(id.0); + p["start"] = json!(1); + p["end"] = json!(2); + s.execute("text.setRangeStyle", &p).map(|_| ()) + }; + let undo = s.doc().unwrap().history.undo.len(); + range(&mut s, json!({"features": ["salt=2"]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(features(&s), [("a".into(), vec![]), ("b".into(), vec!["salt=2".to_string()]), ("cd".into(), vec![])]); + range(&mut s, json!({"features": ["salt=3"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["salt=3"], "the next value replaces it"); + range(&mut s, json!({"featureChanges": ["salt=2", "ss01"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01", "salt=2"], "written in the canonical order"); + range(&mut s, json!({"featureChanges": ["salt=1"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01", "salt"], "salt=1 is a plain salt"); + range(&mut s, json!({"featureChanges": ["salt=2"]})).unwrap(); + let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let back = vectorcraft_format::load(&saved).unwrap(); + let Some(NodeKind::Text(t)) = back.node(id).map(|n| &n.kind) else { panic!("the text survives") }; + assert_eq!(t.runs[1].style.features, ["ss01", "salt=2"], "the native format keeps the string"); + assert!(String::from_utf8_lossy(&saved).contains("\"salt=2\"")); + range(&mut s, json!({"featureChanges": ["-salt"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01"], "-salt turns it off"); + range(&mut s, json!({"featureChanges": ["salt=2"], "features": null})).unwrap(); + range(&mut s, json!({"featureChanges": ["salt=0"]})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01"], "salt=0 turns it off"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(features(&s)[1].1, ["ss01", "salt=2"], "undo takes it back"); + let before = features(&s); + for tag in ["salt=", "salt=x", "salt=65536", "-salt=2", "liga=2", "salt=2=3", "zzzz=2"] { + for key in ["features", "featureChanges"] { + let e = range(&mut s, json!({ key: [tag] })).unwrap_err().to_string(); + assert!(e.contains(&format!("`{tag}`")), "{key} {tag}: {e}"); + } + } + assert_eq!(features(&s), before, "the refused commands change nothing"); +} + /// `text.setStyle {stroke, strokeWidth}` sets the character stroke of all the text or of a range, as /// `text.setRangeStyle` does, and a call with `fill` and `stroke` sets both in one undo step. #[test] @@ -2810,6 +2858,41 @@ fn a_glyph_alternate_replaces_one_character_through_undo_and_reopen() { assert!(s.execute("text.glyphAlternates", &json!({"id": id.0, "start": 2})).unwrap_err().to_string().contains("is not available")); } +/// `glyphAlternate: null` in `text.setRangeStyle` takes a glyph choice away in the same undo step +/// as the other style it sets (a feature: what the Glyphs panel does for a variant a feature +/// draws); anything but null is refused. +#[test] +fn set_range_style_takes_a_glyph_choice_away_with_a_feature() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let _fonts = crate::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the test font builds")); + let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "xay", "size": 20, "font": alternates_family(true)})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": 1, "gid": u1})).unwrap(); + let gids = |s: &Session| vectorcraft_text::layout(db, &obj(s, id)).glyphs.iter().map(|g| g.gid).collect::>(); + assert_eq!(gids(&s)[1], u1); + let undo = s.doc().unwrap().history.undo.len(); + let e = s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": 5})).unwrap_err().to_string(); + assert!(e.contains("takes only null"), "{e}"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": null, "featureChanges": ["ss01"]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert!(obj(&s, id).runs.iter().all(|r| r.style.glyph_alternate.is_none()), "the choice is gone"); + assert_eq!( + gids(&s)[1], + face.substitutions(face.glyph_for('a')).iter().find(|x| &x.feature == b"ss01").map(|x| x.glyphs[0]).unwrap(), + "ss01 draws its glyph" + ); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(gids(&s)[1], u1, "undone: the choice again"); + // With nothing else to set it is still a change. + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 2, "glyphAlternate": null})).unwrap(); + assert_eq!(gids(&s)[1], face.glyph_for('a')); +} + /// The glyph already drawn, chosen again, and null for a character drawn with its default glyph /// leave the document and its undo history as they are. An empty run before the character does /// not stand in for the character's own choice. diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 78113dcd8c..76a04bea84 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -174,11 +174,18 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { .iter() .map(|t| match t.strip_prefix('-') { Some(off) => (off, 0), - None => (t.as_str(), 1), + // `tag=N` picks the font's alternate N: CSS takes the number as the feature's value. + None => { + let on = t.strip_prefix('+').unwrap_or(t); + match on.split_once('=').map(|(tag, n)| (tag, n.parse::())) { + Some((tag, Ok(n))) => (tag, n), + _ => (on, 1), + } + } }) // A feature tag is four letters or digits: anything else could end the CSS string. .filter(|(tag, _)| tag.len() == 4 && tag.bytes().all(|b| b.is_ascii_alphanumeric())) - .map(|(tag, on)| format!("\"{tag}\" {on}")) + .map(|(tag, value)| format!("\"{tag}\" {value}")) .collect(); // Proportional Metrics: `palt` (SVG writes vertical type as outlines). if st.proportional_metrics { diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index eee6e2fbf5..6d0d6489e5 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -123,6 +123,19 @@ fn proportional_metrics_write_palt() { assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]); } +/// A feature with a value (`salt=2`) is written as that value, `-tag` as 0, a plain tag as 1; a +/// tag whose value is not a number is left out. +#[test] +fn feature_values_write_their_number() { + let valued = CharStyle { features: vec!["salt=2".into(), "-liga".into(), "ss01".into(), "swsh=12".into(), "salt=x".into()], ..style(12.0) }; + let d = area_doc(vec![run("ab ", style(12.0)), run("cd", valued)], 280.0, Justify::Left); + let svg = export(&d, &ExportOptions::default()); + let tags = tspans(&svg); + assert_eq!(tags.len(), 2, "{svg}"); + assert!(tags[1].contains("font-feature-settings:"salt" 2, "liga" 0, "ss01" 1, "swsh" 12"), "{}", tags[1]); + assert!(!tags[1].contains("salt=x") && !tags[0].contains("salt"), "{svg}"); +} + /// Character Direction: live text writes each run set Left to Right as a bidi embedding /// (`direction` and `unicode-bidi`). Type with a run set Right to Left is written as outlines. #[test] diff --git a/crates/text/src/context_alternates.rs b/crates/text/src/context_alternates.rs new file mode 100644 index 0000000000..034f65f1d5 --- /dev/null +++ b/crates/text/src/context_alternates.rs @@ -0,0 +1,147 @@ +//! The alternate glyphs of one character as it stands in its text: the glyph the shaper makes for +//! it in its word (a medial letter's medial form, not its isolated one), the alternates the font +//! gives that glyph, and for each the OpenType feature value that draws it on that character alone +//! (`salt=2`, `ss03`). Applying that feature to the character's range swaps the glyph in place +//! while the text stays real Unicode and the word is shaped as one piece, so the letters still +//! join. Adobe's Glyphs panel does the same for its Show › Alternates for Current Selection: +//! + +use kurbo::Point; +use vectorcraft_doc::{CharStyle, TextObject}; + +use crate::{FontDb, GlyphAlternate, OtFeatures, edit, layout}; + +/// The most characters on each side of the selected one that are shaped with it: the word it is in. +const MAX_WORD: usize = 256; + +/// The most alternates tried against the text. +const MAX_CANDIDATES: usize = 512; + +/// Does feature `tag` (as a style lists it, with its prefix and value) choose a glyph among a +/// glyph's alternates: `aalt`, `salt`, `swsh` or a stylistic set `ss01` to `ss20`? +pub fn is_alternate_feature(tag: &str) -> bool { + let name = OtFeatures::tag_name(tag); + matches!(name, "aalt" | "salt" | "swsh") || (name.len() == 4 && name.starts_with("ss") && OtFeatures::known_tag(name)) +} + +/// The tags to change a character's features by, as `featureChanges` takes them, so that it has +/// `tag` as its only feature that chooses an alternate: each such feature of `features` turned +/// off, and then `tag` (`salt=2`) when it is given. Empty when the character already has exactly +/// that. +pub fn alternate_changes(features: &[String], tag: Option<&str>) -> Vec { + let mut changes: Vec = vec![]; + for t in features.iter().filter(|t| is_alternate_feature(t) && !t.starts_with('-')) { + let off = format!("-{}", OtFeatures::tag_name(t)); + if tag.is_none_or(|n| OtFeatures::tag_name(n) != OtFeatures::tag_name(t)) && !changes.contains(&off) { + changes.push(off); + } + } + if let Some(tag) = tag { + let now = OtFeatures::from_tags(features.iter().map(String::as_str)); + let wanted = OtFeatures::from_tags([tag]); + let name = OtFeatures::tag_name(tag); + if now.feature_value(name) != wanted.feature_value(name) { + changes.push(tag.to_string()); + } + } + changes +} + +/// One glyph [`alternates_in_context`] lists. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContextAlternate { + pub gid: u32, + /// The feature that lists the glyph among the default glyph's alternates (`aalt` first). + pub feature: [u8; 4], + /// The feature tag that draws the glyph on the character in its word (`salt=2`), when one does. + pub apply: Option, +} + +/// What [`alternates_in_context`] finds. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContextAlternates { + /// The face that supplied the glyph ([`crate::PositionedGlyph::font_id`]). + pub font_id: u32, + /// The glyph the shaper makes for the character with none of its own alternate features: its + /// form in its position. + pub default: u32, + /// The glyph it is drawn with now. + pub current: u32, + /// The alternates of [`Self::default`] in the font's order (`aalt`, `salt`, `ss01`… as + /// [`crate::FontFace::alternates`]), without it unless `aalt` lists it. + pub alternates: Vec, +} + +/// The glyph of the layout `text` that draws the character at `byte`, as (face id, glyph id). +fn glyph_at(db: &FontDb, text: &TextObject, byte: usize) -> Option<(u32, u32, usize)> { + layout(db, text).glyphs.into_iter().find(|g| g.byte <= byte && byte < g.byte.saturating_add(g.len)).map(|g| (g.font_id, g.gid, g.len)) +} + +/// The alternate glyphs of the character at `byte` of `text` as it stands in its word, from the +/// glyph the shaper makes for it there, and the feature value that draws each on that character +/// alone. The character's own alternate features (`salt`, `ss01`…) are left out when the default +/// glyph is found; each alternate's feature is tried on the character in its word, and the one +/// that draws that glyph there is its `apply` (`salt` before the stylistic sets, `swsh` and +/// `aalt`; a glyph no feature of the font draws in this position has none). `None` when `byte` is +/// not a character of the text or the layout holds no glyph for it. Only the word is shaped (up to +/// 256 characters each side), so the cost does not grow with the text. +pub fn alternates_in_context(db: &FontDb, text: &TextObject, byte: usize) -> Option { + let plain = text.plain_text(); + let ch = plain.get(byte..)?.chars().next()?; + let end = byte + ch.len_utf8(); + let breaks = |c: char| c.is_whitespace() || matches!(c, '\u{2028}' | '\u{2029}'); + let start = plain[..byte].char_indices().rev().take(MAX_WORD).take_while(|(_, c)| !breaks(*c)).last().map_or(byte, |(i, _)| i); + let stop = plain[end..].char_indices().take(MAX_WORD).take_while(|(_, c)| !breaks(*c)).last().map_or(end, |(i, c)| end + i + c.len_utf8()); + let mut probe = TextObject::point(Point::ZERO, "", CharStyle::default()); + probe.runs = edit::slice_runs(&text.runs, start, stop); + probe.vertical = text.vertical; + probe.para = text.para.clone(); + let (a, b) = (byte - start, end - start); + let (_, current, _) = glyph_at(db, &probe, a)?; + let own = |probe: &TextObject, tag: Option<&str>| { + let mut p = probe.clone(); + edit::style_range(&mut p.runs, a, b, |st| { + st.glyph_alternate = None; + st.features.retain(|t| !is_alternate_feature(t)); + st.features.extend(tag.map(str::to_string)); + }); + glyph_at(db, &p, a) + }; + let (default_font, default, len) = own(&probe, None)?; + let face = db.face_by_id(default_font)?; + // A glyph that more than the character makes (a ligature) has no alternates of its own here. + let subs = if len == ch.len_utf8() { face.substitutions(default) } else { vec![] }; + let mut alternates: Vec = vec![]; + let mut tried: Vec<(String, u32)> = vec![]; + for sub in &subs { + let name = String::from_utf8_lossy(&sub.feature).to_string(); + let values = if sub.glyphs.is_empty() { 0 } else { u16::try_from(sub.glyphs.len()).unwrap_or(u16::MAX) }; + for value in 1..=values { + let tag = if value == 1 { name.clone() } else { format!("{name}={value}") }; + if !is_alternate_feature(&tag) || !OtFeatures::known_tag(&tag) || tried.len() >= MAX_CANDIDATES || tried.iter().any(|(t, _)| *t == tag) { + continue; + } + // A single substitution gives its glyph at any value: only the plain tag is tried. + if sub.kind == crate::SubstitutionKind::Single && value > 1 { + continue; + } + if let Some((font, gid, _)) = own(&probe, Some(&tag)) + && font == default_font + { + tried.push((tag, gid)); + } + } + } + for GlyphAlternate { gid, feature } in crate::fontdb::listed(&subs, default) { + let rank = |tag: &str| match OtFeatures::tag_name(tag) { + "salt" => 0, + "swsh" => 2, + "aalt" => 3, + _ => 1, + }; + let mut drawing: Vec<&(String, u32)> = tried.iter().filter(|(_, g)| *g == gid).collect(); + drawing.sort_by_key(|(t, _)| rank(t)); + alternates.push(ContextAlternate { gid, feature, apply: drawing.first().map(|(t, _)| t.clone()) }); + } + Some(ContextAlternates { font_id: default_font, default, current, alternates }) +} diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index ddf9e5a03c..9f2b555e23 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -43,20 +43,32 @@ pub struct OtFeatures { pub swash: bool, /// The features of [`EXTRA_FEATURES`] turned on, one bit each (off by default): stylistic /// sets, alternates, titling, proportional and lining figures, superscript, subscript, - /// numerators and denominators. + /// numerators and denominators, `aalt`. pub extra: u32, + /// The value of each feature of [`EXTRA_FEATURES`] that is on, as a tag `salt=2` gives it: for + /// a feature with alternates, which of them the font draws (the HarfBuzz feature value, 1 for + /// the first). 0 stands for 1, a plain `salt`. Only read for a feature whose bit of + /// [`Self::extra`] is on; use [`Self::set_extra`] to change a bit and its value together. + pub extra_values: [u16; EXTRA_FEATURES.len()], + /// The value `swsh=N` gives swashes ([`Self::swash`]), as [`Self::extra_values`] for `salt`. + pub swash_value: u16, } +/// The largest value a `tag=N` feature takes: a font's glyph ids are 16 bits, so is an alternate's +/// position in a set. +pub const MAX_FEATURE_VALUE: u16 = u16::MAX; + /// The most entries of a style's feature list read: every segment of its text reads the list again. pub const MAX_STYLE_FEATURES: usize = 256; /// Features beyond the named fields, off unless asked for, each bit `i` of [`OtFeatures::extra`]: /// stylistic sets `ss01`–`ss20`, stylistic alternates `salt`, titling `titl`, proportional `pnum` -/// and lining `lnum` figures, superscript `sups`, subscript `subs`, numerators `numr` and -/// denominators `dnom`. -pub const EXTRA_FEATURES: [&str; 28] = [ +/// and lining `lnum` figures, superscript `sups`, subscript `subs`, numerators `numr`, +/// denominators `dnom` and access all alternates `aalt`. +/// +pub const EXTRA_FEATURES: [&str; 29] = [ "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", "ss11", "ss12", "ss13", "ss14", "ss15", "ss16", "ss17", "ss18", - "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", + "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", "aalt", ]; impl Default for OtFeatures { @@ -73,12 +85,53 @@ impl Default for OtFeatures { ordinals: false, swash: false, extra: 0, + extra_values: [0; EXTRA_FEATURES.len()], + swash_value: 0, } } } -fn f(tag: &[u8; 4], on: bool) -> Feature { - Feature::new(Tag::new(tag), on as u32, ..) +/// A feature of `value` over the whole text: 1 and 0 turn it on and off, more picks the font's +/// alternate of that number. +fn f(tag: &[u8; 4], value: impl Into) -> Feature { + Feature::new(Tag::new(tag), value.into(), ..) +} + +/// What a feature tag of a style's list says about one feature ([`parse_tag`]). +struct ParsedTag<'a> { + /// The tag without a `-` or `+` prefix or a `=N` value. + name: &'a str, + /// On (`tag`, `+tag`, `tag=N` for `N` from 1) or off (`-tag`, `tag=0`). + on: bool, + /// The value `tag=N` gives; 1 for a plain tag. + value: u16, +} + +/// Can feature `name` take a value (`salt=2`)? The features of [`EXTRA_FEATURES`] and `swsh` can: +/// each substitutes glyphs, and the value says which alternate. +fn takes_value(name: &str) -> bool { + name == "swsh" || EXTRA_FEATURES.contains(&name) +} + +/// Read a feature tag: `tag`, `+tag`, `-tag` or, for a feature that takes a value, `tag=N` (`N` +/// from 0 to [`MAX_FEATURE_VALUE`] in decimal digits; `-tag=N` is refused). `None` for a tag this +/// set does not understand. +fn parse_tag(tag: &str) -> Option> { + let (off, rest) = match tag.strip_prefix('-') { + Some(r) => (true, r), + None => (false, tag.strip_prefix('+').unwrap_or(tag)), + }; + let (name, value) = match rest.split_once('=') { + None => (rest, None), + Some((name, digits)) => { + if off || digits.is_empty() || digits.len() > 5 || !digits.bytes().all(|b| b.is_ascii_digit()) || !takes_value(name) { + return None; + } + (name, Some(u16::try_from(digits.parse::().ok()?).ok()?)) + } + }; + let known = matches!(name, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&name); + known.then_some(ParsedTag { name, on: !off && value != Some(0), value: value.unwrap_or(1) }) } impl OtFeatures { @@ -104,6 +157,8 @@ impl OtFeatures { ordinals: true, swash: true, extra: u32::MAX >> (32 - EXTRA_FEATURES.len()), + extra_values: [0; EXTRA_FEATURES.len()], + swash_value: 0, }; change(&mut off); change(&mut on); @@ -137,28 +192,68 @@ impl OtFeatures { (self.swash, d.swash, "swsh"), ] { if on != def { - v.push(if on { tag.to_string() } else { format!("-{tag}") }); + v.push(match (on, tag, self.swash_value) { + (true, "swsh", n @ 2..) => format!("{tag}={n}"), + (true, ..) => tag.to_string(), + (false, ..) => format!("-{tag}"), + }); } } - v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(_, t)| t.to_string())); + v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(i, t)| match self.extra_values.get(i) { + None | Some(0 | 1) => t.to_string(), + Some(n) => format!("{t}={n}"), + })); v } - /// Is `tag` (optionally prefixed with `-` or `+`) one this set understands? + /// Is `tag` one this set understands? A feature tag, optionally prefixed with `-` or `+`, or + /// `tag=N` (`N` from 0 to [`MAX_FEATURE_VALUE`], no prefix but `+`) for a feature of + /// [`EXTRA_FEATURES`] or `swsh`, which picks the font's alternate number `N` (`salt=2`; 0 is + /// off, 1 is a plain tag). pub fn known_tag(tag: &str) -> bool { + parse_tag(tag).is_some() + } + + /// The feature a tag of a style's list names, without its `-` or `+` prefix or `=N` value + /// (`salt` for `-salt`, `+salt` and `salt=2`). + pub fn tag_name(tag: &str) -> &str { let t = tag.strip_prefix(['-', '+']).unwrap_or(tag); - matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&t) + t.split_once('=').map_or(t, |(name, _)| name) + } + + /// The value feature `tag` of [`EXTRA_FEATURES`] or `swsh` is on with: `Some(1)` for a plain + /// tag, `Some(N)` for `tag=N`, `None` when it is off or is another tag. + pub fn feature_value(&self, tag: &str) -> Option { + if tag == "swsh" { + return self.swash.then_some(self.swash_value.max(1)); + } + let i = EXTRA_FEATURES.iter().position(|e| *e == tag)?; + (self.extra & (1 << i) != 0).then(|| self.extra_values.get(i).copied().unwrap_or(0).max(1)) + } + + /// Turn the features of [`EXTRA_FEATURES`] in `mask` (bit `i` for the feature `i`) on, with + /// value 1 (a plain tag), or off. + pub fn set_extra(&mut self, mask: u32, on: bool) { + for (i, value) in self.extra_values.iter_mut().enumerate() { + if mask & (1 << i) != 0 { + *value = 0; + } + } + if on { + self.extra |= mask; + } else { + self.extra &= !mask; + } } /// This set with `tags` applied on top. pub fn with_tags<'a>(mut self, tags: impl IntoIterator) -> Self { let o = &mut self; for t in tags { - let (on, t) = match t.strip_prefix('-') { - Some(r) => (false, r), - None => (true, t.strip_prefix('+').unwrap_or(t)), - }; - match t { + let Some(ParsedTag { name, on, value }) = parse_tag(t) else { continue }; + // 1 is a plain tag: only a value from 2 is kept. + let kept = if on && value > 1 { value } else { 0 }; + match name { "liga" => o.ligatures = on, "calt" => o.contextual = on, "dlig" => o.discretionary_ligatures = on, @@ -167,13 +262,15 @@ impl OtFeatures { "onum" => o.oldstyle_figures = on, "tnum" => o.tabular_figures = on, "ordn" => o.ordinals = on, - "swsh" => o.swash = on, + "swsh" => { + o.swash = on; + o.swash_value = kept; + } _ => { - if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == t) { - if on { - o.extra |= 1 << i; - } else { - o.extra &= !(1 << i); + if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == name) { + o.set_extra(1 << i, on); + if let Some(slot) = o.extra_values.get_mut(i) { + *slot = kept; } } } @@ -214,12 +311,12 @@ impl OtFeatures { (s.swash, b"swsh"), ] { if on { - v.push(f(tag, true)); + v.push(f(tag, if tag == b"swsh" { u32::from(s.swash_value.max(1)) } else { 1 })); } } for (i, tag) in EXTRA_FEATURES.iter().enumerate() { if let (true, Ok(t)) = (s.extra & (1 << i) != 0, <[u8; 4]>::try_from(tag.as_bytes())) { - v.push(f(&t, true)); + v.push(f(&t, u32::from(s.extra_values.get(i).copied().unwrap_or(0).max(1)))); } } if self.vertical { @@ -354,6 +451,69 @@ mod tests { assert_eq!(OtFeatures::from_tags(start.iter().copied()).with_tags(tags.iter().map(String::as_str)), want, "{start:?}"); } } + + /// `tag=N` gives a feature a value: parsed, validated, kept in the style's list as written + /// (1 is a plain tag, 0 is off), replaced by the next value or by `-tag`, and given to the + /// shaper as that value. + #[test] + fn feature_values_are_parsed_validated_and_shaped() { + for t in ["salt=2", "+salt=2", "ss03=12", "aalt=65535", "swsh=3", "salt=0", "salt=01", "titl=2"] { + assert!(OtFeatures::known_tag(t), "{t}"); + } + for t in [ + "salt=", + "salt=-1", + "salt=65536", + "salt=1.5", + "salt=x", + "-salt=2", + "liga=2", + "dlig=1", + "zzzz=2", + "salt==2", + "=2", + "salt=2=3", + "salt=123456", + "salt= 2", + ] { + assert!(!OtFeatures::known_tag(t), "{t}"); + } + assert_eq!(OtFeatures::tag_name("salt=2"), "salt"); + assert_eq!((OtFeatures::tag_name("-ss01"), OtFeatures::tag_name("+salt=3"), OtFeatures::tag_name("liga")), ("ss01", "salt", "liga")); + let o = OtFeatures::from_tags(["salt=2", "ss01", "-liga", "swsh=4"]); + assert_eq!(o.to_tags(), ["-liga", "swsh=4", "ss01", "salt=2"]); + assert_eq!( + (o.feature_value("salt"), o.feature_value("ss01"), o.feature_value("swsh"), o.feature_value("ss02")), + (Some(2), Some(1), Some(4), None) + ); + // The next value replaces the last, `salt=1` is a plain `salt`, `salt=0` and `-salt` are off. + assert_eq!(OtFeatures::from_tags(["salt=2", "salt=3"]).to_tags(), ["salt=3"]); + assert_eq!(OtFeatures::from_tags(["salt=3", "salt"]).to_tags(), ["salt"]); + assert_eq!(OtFeatures::from_tags(["salt=1"]).to_tags(), ["salt"]); + assert_eq!(OtFeatures::from_tags(["salt=2", "-salt"]).to_tags(), Vec::::new()); + assert_eq!(OtFeatures::from_tags(["salt=2", "salt=0"]).to_tags(), Vec::::new()); + assert_eq!(OtFeatures::from_tags(["salt=2", "-salt", "salt"]).to_tags(), ["salt"], "a value does not outlive being turned off"); + // A tag with a value this set does not know is ignored. + assert_eq!(OtFeatures::from_tags(["salt=x", "liga=2"]), OtFeatures::default()); + // The panel's own switches turn a feature on at value 1. + let mut f = OtFeatures::from_tags(["salt=3"]); + f.set_extra(1 << 20, false); + f.set_extra(1 << 20, true); + assert_eq!(f.to_tags(), ["salt"]); + let values = |tags: &[&str]| -> Vec<(String, u32)> { + let st = CharStyle { features: tags.iter().map(|t| t.to_string()).collect(), ..CharStyle::default() }; + OtFeatures::default() + .resolve(&st) + .iter() + .filter(|f| f.value > 0 && f.tag != harfrust::Tag::new(b"kern")) + .map(|f| (f.tag.to_string(), f.value)) + .collect() + }; + assert_eq!(values(&["salt=2"]), [("salt".to_string(), 2)]); + assert_eq!(values(&["salt"]), [("salt".to_string(), 1)]); + assert_eq!(values(&["swsh=3", "ss01"]), [("swsh".to_string(), 3), ("ss01".to_string(), 1)]); + assert!(values(&["-salt", "salt=0"]).is_empty()); + } use crate::{FontDb, layout}; use vectorcraft_doc::TextObject; use vectorcraft_geom::Point; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d42e8f3516..c7d116a29d 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -24,6 +24,7 @@ mod db; mod dirs; mod face; mod glyph_pen; +mod gsub; mod lookup; mod names; mod scan; @@ -32,6 +33,7 @@ mod traits; mod wanted; pub use alternates::GlyphAlternate; +pub(crate) use alternates::listed; pub use build::style_weight; #[cfg(test)] use cache::GlyphCache; @@ -49,6 +51,7 @@ use face::Instances; pub(crate) use face::MAX_LIVE_INSTANCES; pub use face::{FALLBACK_FAMILY, FontFace, IcfMargins}; pub(crate) use glyph_pen::FlipPen; +pub use gsub::{Substitution, SubstitutionKind}; #[cfg(not(target_arch = "wasm32"))] use names::SysFallback; #[cfg(test)] diff --git a/crates/text/src/fontdb/alternates.rs b/crates/text/src/fontdb/alternates.rs index 2a582bbe71..d74a55ff64 100644 --- a/crates/text/src/fontdb/alternates.rs +++ b/crates/text/src/fontdb/alternates.rs @@ -1,16 +1,16 @@ //! The alternate glyphs a face's `GSUB` table gives one character: the Glyphs panel's Show › //! Alternates for Current Selection. The application finds the character's default glyph in the -//! `cmap` and looks it up in the coverage of each lookup of the features that substitute one glyph -//! for another, as the OpenType registry describes `aalt`. +//! `cmap` and lists what the features that substitute one glyph for another give it, as the +//! OpenType registry describes `aalt` ([`FontFace::substitutions`] reads them). //! //! use skrifa::MetadataProvider; use skrifa::raw::TableProvider; -use skrifa::raw::tables::gsub::{AlternateSubstFormat1, SingleSubst, SubstitutionSubtables}; use vectorcraft_doc::GlyphChoice; use super::FontFace; +use super::gsub::Substitution; /// One glyph [`FontFace::alternates`] lists: its id and the feature it was found in (`aalt`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -19,108 +19,52 @@ pub struct GlyphAlternate { pub feature: [u8; 4], } -/// Caps on what a damaged or very large `GSUB` table can make [`FontFace::alternates`] read: -/// feature records, lookups per feature and in all, subtables per lookup and in all, glyphs per -/// alternate set and glyphs listed in all. -const MAX_FEATURES: usize = 4096; -const MAX_FEATURE_LOOKUPS: usize = 256; -const MAX_LOOKUPS: usize = 4096; -const MAX_SUBTABLES: usize = 256; -const MAX_SUBTABLE_READS: usize = 16384; -const MAX_SET: usize = 512; +/// The most glyphs [`FontFace::alternates`] lists. const MAX_ALTERNATES: usize = 1024; -/// The other discretionary features that substitute one glyph for another (historical forms, -/// justification alternates, alternate annotation forms, ornaments, titling), after `cv99`, in -/// tag order. -const OTHER_FEATURES: [&[u8; 4]; 5] = [b"hist", b"jalt", b"nalt", b"ornm", b"titl"]; - -/// Where the glyphs of feature `tag` come in the list: `aalt`, `salt`, `ss01` to `ss20`, `swsh`, -/// `cv01` to `cv99`, then [`OTHER_FEATURES`]. `None` for a feature that is not listed. -fn feature_rank(tag: [u8; 4]) -> Option<(u8, u8)> { - let number = |t: &[u8]| std::str::from_utf8(t).ok().filter(|s| s.bytes().all(|b| b.is_ascii_digit())).and_then(|s| s.parse::().ok()); - match &tag { - b"aalt" => Some((0, 0)), - b"salt" => Some((1, 0)), - [b's', b's', ..] => number(&tag[2..]).filter(|n| (1..=20).contains(n)).map(|n| (2, n)), - b"swsh" => Some((3, 0)), - [b'c', b'v', ..] => number(&tag[2..]).filter(|n| (1..=99).contains(n)).map(|n| (4, n)), - _ => OTHER_FEATURES.iter().position(|t| **t == tag).map(|i| (5, i as u8)), +/// The alternates of glyph `default` that its substitutions `subs` ([`FontFace::substitutions`]) +/// list, in their order, each glyph once with the first feature that gives it: the default glyph +/// itself only when `aalt` gives it. +pub(crate) fn listed(subs: &[Substitution], default: u32) -> Vec { + let mut out: Vec = vec![]; + for substitution in subs { + for &gid in &substitution.glyphs { + let wanted = gid != default || substitution.feature == *b"aalt"; + if wanted && out.len() < MAX_ALTERNATES && !out.iter().any(|a| a.gid == gid) { + out.push(GlyphAlternate { gid, feature: substitution.feature }); + } + } } + out } impl FontFace { /// The face's alternate glyphs for `c`, from its `GSUB` table: the glyphs single substitutions /// (lookup type 1) and alternate substitutions (type 3, the whole set in the font's order), in - /// extension lookups (type 7) too, put in place of `c`'s default glyph. `aalt` comes first, - /// then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and - /// `titl`; within a feature its lookups go in lookup order. A glyph found again is left out. - /// The default glyph is listed only when `aalt` lists it. Ligatures and contextual - /// substitutions are not read. Empty when the face does not map `c`, has no `GSUB` table, or - /// the table cannot be read; glyph ids past the face's glyph count are left out. + /// extension lookups (type 7) too, put in place of `c`'s default glyph + /// ([`FontFace::substitutions`]). `aalt` comes first, then `salt`, `ss01` to `ss20`, `swsh`, + /// `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and `titl`; within a feature its lookups go + /// in lookup order. A glyph found again is left out. The default glyph is listed only when + /// `aalt` lists it. Ligatures and contextual substitutions are not read. Empty when the face + /// does not map `c`, has no `GSUB` table, or the table cannot be read; glyph ids past the face's + /// glyph count are left out. pub fn alternates(&self, c: char) -> Vec { - let mut out: Vec = vec![]; - let Some(f) = self.skrifa() else { return out }; - let Some(default) = f.charmap().map(c) else { return out }; - let Ok(gsub) = f.gsub() else { return out }; - let (Ok(features), Ok(lookups)) = (gsub.feature_list(), gsub.lookup_list()) else { return out }; - let glyphs = f.maxp().map_or(0, |m| u32::from(m.num_glyphs())); - // The lookups of the listed features, by rank and lookup index. - // The listed feature records are read in rank order, so the cap on lookups in all leaves - // out the lowest ranked ones. - let records = features.feature_records(); - let mut ranked: Vec<((u8, u8), usize, [u8; 4])> = records - .iter() - .take(MAX_FEATURES) - .enumerate() - .filter_map(|(i, r)| { - let tag = r.feature_tag().to_be_bytes(); - feature_rank(tag).map(|rank| (rank, i, tag)) + let Some(default) = self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()) else { return vec![] }; + listed(&self.substitutions(default), default) + } + + /// The glyphs no character maps (its `cmap` gives no character for them), by glyph id, without + /// `.notdef`: the positional forms, alternates and ligatures the `GSUB` table reaches and any + /// other glyph of the font. Read once for the face. + pub fn unencoded_glyphs(&self) -> std::sync::Arc<[u32]> { + self.unencoded + .get_or_init(|| { + let Some(font) = self.skrifa() else { return std::sync::Arc::from([]) }; + let mapped: std::collections::HashSet = font.charmap().mappings().map(|(_, g)| g.to_u32()).collect(); + let count = font.maxp().map_or(0, |m| u32::from(m.num_glyphs())); + (1..count).filter(|g| !mapped.contains(g)).collect() }) - .collect(); - ranked.sort_unstable_by_key(|r| (r.0, r.1)); - let mut wanted: Vec<((u8, u8), u16, [u8; 4])> = vec![]; - for (rank, i, tag) in ranked { - let room = MAX_LOOKUPS.saturating_sub(wanted.len()); - if room == 0 { - break; - } - let Some(record) = records.get(i) else { continue }; - let Ok(feature) = record.feature(features.offset_data()) else { continue }; - wanted.extend(feature.lookup_list_indices().iter().take(MAX_FEATURE_LOOKUPS.min(room)).map(|i| (rank, i.get(), tag))); - } - wanted.sort_unstable_by_key(|w| (w.0, w.1)); - wanted.dedup_by_key(|w| (w.0, w.1)); - let mut push = |gid: u32, feature: [u8; 4]| { - let listed = gid != default.to_u32() || feature == *b"aalt"; - if listed && gid < glyphs && out.len() < MAX_ALTERNATES && !out.iter().any(|a| a.gid == gid) { - out.push(GlyphAlternate { gid, feature }); - } - }; - let all = lookups.lookups(); - let mut reads = MAX_SUBTABLE_READS; - for (_, index, tag) in wanted { - if reads == 0 { - break; - } - let Ok(subtables) = all.get(usize::from(index)).and_then(|l| l.subtables()) else { continue }; - let n = MAX_SUBTABLES.min(reads); - // A glyph takes the first subtable of a lookup whose coverage holds it. - match subtables { - SubstitutionSubtables::Single(s) => { - if let Some(gid) = s.iter().take(n).inspect(|_| reads -= 1).flatten().find_map(|t| single(&t, default.to_u32())) { - push(gid, tag); - } - } - SubstitutionSubtables::Alternate(s) => { - if let Some(set) = s.iter().take(n).inspect(|_| reads -= 1).flatten().find_map(|t| alternate_set(&t, default.to_u32())) { - set.into_iter().for_each(|gid| push(gid, tag)); - } - } - _ => {} - } - } - out + .clone() } /// How many glyphs the face has (its `maxp` table; 0 when it can't be read). @@ -141,25 +85,3 @@ impl FontFace { GlyphChoice { ch: c, gid, font: self.postscript_name(), version: self.version.clone() } } } - -/// The glyph single substitution `t` puts in place of `gid`, when its coverage holds `gid`. -fn single(t: &SingleSubst<'_>, gid: u32) -> Option { - match t { - SingleSubst::Format1(t) => { - t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; - // The delta is added modulo 65536. - Some((gid as i32 + i32::from(t.delta_glyph_id())).rem_euclid(0x1_0000) as u32) - } - SingleSubst::Format2(t) => { - let i = t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; - t.substitute_glyph_ids().get(usize::from(i)).map(|g| u32::from(g.get().to_u16())) - } - } -} - -/// The alternate set of alternate substitution `t` for `gid`, when its coverage holds `gid`. -fn alternate_set(t: &AlternateSubstFormat1<'_>, gid: u32) -> Option> { - let i = t.coverage().ok()?.get(skrifa::GlyphId::new(gid))?; - let set = t.alternate_sets().get(usize::from(i)).ok()?; - Some(set.alternate_glyph_ids().iter().take(MAX_SET).map(|g| u32::from(g.get().to_u16())).collect()) -} diff --git a/crates/text/src/fontdb/build.rs b/crates/text/src/fontdb/build.rs index 40bcb1df1b..261e1b2877 100644 --- a/crates/text/src/fontdb/build.rs +++ b/crates/text/src/fontdb/build.rs @@ -78,6 +78,8 @@ pub(super) fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Opt colour_bitmaps, colour_layers, svg_glyphs, + gsub: std::sync::OnceLock::new(), + unencoded: std::sync::OnceLock::new(), }) } diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index 5ab31eeae0..3e03e5c1da 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -199,6 +199,11 @@ pub struct FontFace { pub(super) colour_layers: bool, /// Has an `SVG ` table: OpenType SVG glyphs ([`FontDb::glyph_colour`]). pub(super) svg_glyphs: bool, + /// What the `GSUB` table substitutes ([`Self::substitutions`]), read the first time it is asked + /// for. + pub(super) gsub: std::sync::OnceLock, + /// [`Self::unencoded_glyphs`], read once. + pub(super) unencoded: std::sync::OnceLock>, } /// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance diff --git a/crates/text/src/fontdb/gsub.rs b/crates/text/src/fontdb/gsub.rs new file mode 100644 index 0000000000..3b07aad668 --- /dev/null +++ b/crates/text/src/fontdb/gsub.rs @@ -0,0 +1,298 @@ +//! What a face's `GSUB` table substitutes for a glyph: the alternates of one glyph (the Glyphs +//! panel's Show › Alternates for Current Selection), the feature that gives a glyph no character +//! maps to (Show › Entire Font) and glyph names. Read once for a face, the first time one is asked +//! for, and kept with it ([`FontFace`]). +//! +//! A feature of alternates (an alternate substitution, lookup type 3; `salt`, `aalt`, `swsh`) +//! takes the alternate its value says: with the feature at value `N`, a glyph in its coverage is +//! replaced by the `N`th glyph of its set (the first at value 1, none at 0 or past the set), which +//! is how the shaper reads it. A feature of single substitutions (type 1; `ss01`, `smcp`) replaces +//! the glyph at any value from 1. +//! +//! + +use std::collections::{HashMap, HashSet}; + +use skrifa::MetadataProvider; +use skrifa::raw::TableProvider; +use skrifa::raw::tables::gsub::{AlternateSubstFormat1, LigatureSubstFormat1, MultipleSubstFormat1, SingleSubst, SubstitutionSubtables}; + +use super::FontFace; + +/// Caps on what a damaged or very large `GSUB` table can make a face read: feature records, +/// lookups per feature and in all, subtables per lookup and in all, glyphs per alternate set, and +/// glyphs and substitutions listed in all. +pub(super) const MAX_FEATURES: usize = 4096; +const MAX_FEATURE_LOOKUPS: usize = 256; +const MAX_LOOKUPS: usize = 4096; +const MAX_SUBTABLES: usize = 256; +const MAX_SUBTABLE_READS: usize = 16384; +pub(super) const MAX_SET: usize = 512; +const MAX_ENTRIES: usize = 1 << 18; + +/// The other discretionary features that substitute one glyph for another (historical forms, +/// justification alternates, alternate annotation forms, ornaments, titling), after `cv99`, in +/// tag order. +const OTHER_FEATURES: [&[u8; 4]; 5] = [b"hist", b"jalt", b"nalt", b"ornm", b"titl"]; + +/// Where the substitutions of feature `tag` come in a glyph's list: `aalt`, `salt`, `ss01` to +/// `ss20`, `swsh`, `cv01` to `cv99`, then [`OTHER_FEATURES`]. `None` for a feature that is not +/// listed. +fn feature_rank(tag: [u8; 4]) -> Option<(u8, u8)> { + let number = |t: &[u8]| std::str::from_utf8(t).ok().filter(|s| s.bytes().all(|b| b.is_ascii_digit())).and_then(|s| s.parse::().ok()); + match &tag { + b"aalt" => Some((0, 0)), + b"salt" => Some((1, 0)), + [b's', b's', ..] => number(&tag[2..]).filter(|n| (1..=20).contains(n)).map(|n| (2, n)), + b"swsh" => Some((3, 0)), + [b'c', b'v', ..] => number(&tag[2..]).filter(|n| (1..=99).contains(n)).map(|n| (4, n)), + _ => OTHER_FEATURES.iter().position(|t| **t == tag).map(|i| (5, i as u8)), + } +} + +/// The rank of the features that give no listed alternates but reach glyphs ([`FontFace::feature_reaching`]). +const UNLISTED: (u8, u8) = (9, 0); + +/// How a feature gives its glyphs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum SubstitutionKind { + /// One glyph, whatever the feature's value is from 1 (a single substitution, type 1). + Single, + /// A set of glyphs, the one at the feature's value (an alternate substitution, type 3). + Alternate, +} + +/// What one feature substitutes for a glyph ([`FontFace::substitutions`]). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Substitution { + /// The feature's tag (`salt`). + pub feature: [u8; 4], + pub kind: SubstitutionKind, + /// The glyphs, in the font's order: the one glyph of a single substitution, or the alternate + /// set (the feature at value `N` gives the `N`th). + pub glyphs: Vec, +} + +impl Substitution { + /// The value the feature takes to give `glyph` (`salt=2` for 2), `None` when it never gives it. + pub fn value_for(&self, glyph: u32) -> Option { + match self.kind { + SubstitutionKind::Single => (self.glyphs.first() == Some(&glyph)).then_some(1), + SubstitutionKind::Alternate => self.glyphs.iter().position(|g| *g == glyph).and_then(|i| u16::try_from(i + 1).ok()), + } + } +} + +/// A face's substitutions, by the glyph they replace and by the glyph they give. +#[derive(Default)] +pub(super) struct GsubIndex { + /// The listed features' ([`feature_rank`]) substitutions of each glyph, by feature rank and + /// lookup. + by_input: HashMap>, + /// The first feature, listed ones first, that gives each glyph: by a single, multiple, + /// alternate or ligature substitution. + reached: HashMap, +} + +/// The glyph single substitution `t` puts in place of each glyph its coverage holds. +fn single_pairs(t: &SingleSubst<'_>, room: usize) -> Vec<(u32, u32)> { + match t { + SingleSubst::Format1(t) => { + let Ok(coverage) = t.coverage() else { return vec![] }; + // The delta is added modulo 65536. + let delta = i32::from(t.delta_glyph_id()); + coverage.iter().take(room).map(|g| (g.to_u32(), (g.to_u32() as i32 + delta).rem_euclid(0x1_0000) as u32)).collect() + } + SingleSubst::Format2(t) => { + let Ok(coverage) = t.coverage() else { return vec![] }; + let out = t.substitute_glyph_ids(); + coverage.iter().zip(out).take(room).map(|(g, to)| (g.to_u32(), u32::from(to.get().to_u16()))).collect() + } + } +} + +/// The alternate set of each glyph alternate substitution `t`'s coverage holds. +fn alternate_sets(t: &AlternateSubstFormat1<'_>, room: usize) -> Vec<(u32, Vec)> { + let Ok(coverage) = t.coverage() else { return vec![] }; + let sets = t.alternate_sets(); + coverage + .iter() + .enumerate() + .take(room) + .filter_map(|(i, g)| { + let set = sets.get(i).ok()?; + Some((g.to_u32(), set.alternate_glyph_ids().iter().take(MAX_SET).map(|g| u32::from(g.get().to_u16())).collect())) + }) + .collect() +} + +/// The glyphs multiple substitution `t` puts in place of its covered glyphs, and the ligature +/// glyphs of ligature substitution `l`, as (glyph, glyphs it becomes). +fn multiple_pairs(t: &MultipleSubstFormat1<'_>, room: usize) -> Vec<(u32, Vec)> { + let Ok(coverage) = t.coverage() else { return vec![] }; + let sequences = t.sequences(); + coverage + .iter() + .enumerate() + .take(room) + .filter_map(|(i, g)| { + let sequence = sequences.get(i).ok()?; + Some((g.to_u32(), sequence.substitute_glyph_ids().iter().map(|g| u32::from(g.get().to_u16())).collect())) + }) + .collect() +} + +fn ligature_glyphs(t: &LigatureSubstFormat1<'_>, room: usize) -> Vec { + let Ok(coverage) = t.coverage() else { return vec![] }; + let sets = t.ligature_sets(); + let mut out = vec![]; + for (i, _) in coverage.iter().enumerate().take(room) { + let Ok(set) = sets.get(i) else { continue }; + out.extend(set.ligatures().iter().flatten().take(room).map(|l| u32::from(l.ligature_glyph().to_u16()))); + if out.len() >= room { + break; + } + } + out +} + +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. + fn read(face: &FontFace) -> Self { + let mut index = Self::default(); + let Some(font) = face.skrifa() else { return index }; + let Ok(gsub) = font.gsub() else { return index }; + let (Ok(features), Ok(lookups)) = (gsub.feature_list(), gsub.lookup_list()) else { return index }; + let glyphs = font.maxp().map_or(0, |m| u32::from(m.num_glyphs())); + // The lookups of the features, by rank and lookup index. The feature records are read in + // rank order, so the cap on lookups in all leaves out the lowest ranked ones. + let records = features.feature_records(); + let mut ranked: Vec<((u8, u8), usize, [u8; 4])> = records + .iter() + .take(MAX_FEATURES) + .enumerate() + .map(|(i, r)| { + let tag = r.feature_tag().to_be_bytes(); + (feature_rank(tag).unwrap_or(UNLISTED), i, tag) + }) + .collect(); + ranked.sort_unstable_by_key(|r| (r.0, r.1)); + let mut wanted: Vec<((u8, u8), u16, [u8; 4])> = vec![]; + for (rank, i, tag) in ranked { + let room = MAX_LOOKUPS.saturating_sub(wanted.len()); + if room == 0 { + break; + } + let Some(record) = records.get(i) else { continue }; + let Ok(feature) = record.feature(features.offset_data()) else { continue }; + wanted.extend(feature.lookup_list_indices().iter().take(MAX_FEATURE_LOOKUPS.min(room)).map(|i| (rank, i.get(), tag))); + } + wanted.sort_by_key(|w| (w.0, w.1)); + wanted.dedup_by_key(|w| (w.0, w.1)); + let all = lookups.lookups(); + let mut reads = MAX_SUBTABLE_READS; + let mut entries = 0_usize; + for (rank, lookup, tag) in wanted { + if reads == 0 || entries >= MAX_ENTRIES { + break; + } + let Ok(subtables) = all.get(usize::from(lookup)).and_then(|l| l.subtables()) else { continue }; + let n = MAX_SUBTABLES.min(reads); + let listed = rank != UNLISTED; + // A glyph takes the first subtable of a lookup whose coverage holds it. + let mut seen: HashSet = HashSet::new(); + let mut note = |index: &mut Self, input: u32, kind: SubstitutionKind, out: Vec| { + if !seen.insert(input) { + return; + } + let out: Vec = out.into_iter().filter(|g| *g < glyphs).collect(); + for g in &out { + if *g != input && index.reached.len() < MAX_ENTRIES { + index.reached.entry(*g).or_insert(tag); + } + } + if listed && !out.is_empty() && entries < MAX_ENTRIES { + entries += 1; + index.by_input.entry(input).or_default().push(Substitution { feature: tag, kind, glyphs: out }); + } + }; + match subtables { + SubstitutionSubtables::Single(s) => { + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for (input, to) in single_pairs(&t, MAX_ENTRIES) { + note(&mut index, input, SubstitutionKind::Single, vec![to]); + } + } + } + SubstitutionSubtables::Alternate(s) => { + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for (input, set) in alternate_sets(&t, MAX_ENTRIES) { + note(&mut index, input, SubstitutionKind::Alternate, set); + } + } + } + SubstitutionSubtables::Multiple(s) => { + let mut covered: HashSet = HashSet::new(); + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for (input, to) in multiple_pairs(&t, MAX_ENTRIES) { + // Not listed as alternates, only reached. + if covered.insert(input) { + for g in to.into_iter().filter(|g| *g < glyphs && *g != input) { + if index.reached.len() < MAX_ENTRIES { + index.reached.entry(g).or_insert(tag); + } + } + } + } + } + } + SubstitutionSubtables::Ligature(s) => { + for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { + for g in ligature_glyphs(&t, MAX_ENTRIES).into_iter().filter(|g| *g < glyphs) { + if index.reached.len() < MAX_ENTRIES { + index.reached.entry(g).or_insert(tag); + } + } + } + } + _ => {} + } + } + index + } +} + +impl FontFace { + fn gsub_index(&self) -> &GsubIndex { + self.gsub.get_or_init(|| GsubIndex::read(self)) + } + + /// What the features `aalt`, `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, + /// `jalt`, `nalt`, `ornm` and `titl` of the face's `GSUB` table substitute for glyph `gid`, in + /// that order of features and within a feature in lookup order: the glyph of each single + /// substitution (lookup type 1) and the whole set of each alternate substitution (type 3), in + /// extension lookups (type 7) too, as the shaper reads them. A feature gives the glyph its + /// first substitution (the first subtable of the lookup whose coverage holds it). Ligatures and + /// contextual substitutions are not read, nor is the choice of a variable font's instance of + /// `FeatureVariations`. Empty when the face has no `GSUB` table or it cannot be read; glyph ids + /// past the face's glyph count are left out. + pub fn substitutions(&self, gid: u32) -> Vec { + self.gsub_index().by_input.get(&gid).cloned().unwrap_or_default() + } + + /// The feature of the `GSUB` table that gives glyph `gid` as a substitute (by a single, + /// multiple, alternate or ligature substitution), a listed feature ([`Self::substitutions`]) + /// before another (`init`, `liga`), if any does. Not read through a contextual lookup. + pub fn feature_reaching(&self, gid: u32) -> Option<[u8; 4]> { + self.gsub_index().reached.get(&gid).copied() + } + + /// The name of glyph `gid` from the font's `post` or `CFF` table: `None` for a glyph past the + /// face's glyphs, or when the font has no names (the ids are then `gid12`). + pub fn glyph_name(&self, gid: u32) -> Option { + let font = self.skrifa()?; + let name = font.glyph_names().get(skrifa::GlyphId::new(gid))?; + (!name.is_synthesized()).then(|| name.as_str().to_string()) + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 9763ea4b6d..b593594fbf 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -13,6 +13,7 @@ mod bitmap; mod colr; mod composer; +mod context_alternates; pub mod craft_fonts; pub mod edit; pub mod embed; @@ -32,12 +33,15 @@ pub mod test_fonts; pub mod thread; pub use bitmap::GlyphImage; +pub use context_alternates::{ContextAlternate, ContextAlternates, alternate_changes, alternates_in_context, is_alternate_feature}; pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont}; -pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; +pub use features::{ + EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_FEATURE_VALUE, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by, +}; pub use fontdb::{ - FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, PlatformFontFiles, StylisticSet, WantedFont, - WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined, - style_weight, system_font_dirs, user_font_dirs, + FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, PlatformFontFiles, StylisticSet, Substitution, + SubstitutionKind, WantedFont, WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, + set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, user_font_dirs, }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; use std::sync::Arc; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 1a6716c5f2..60661795c5 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -769,8 +769,9 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: /// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the /// character style's name, leading, baseline shift, rotation, Character Alignment, Character /// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its -/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard -/// ligatures alike ([`crate::features::ligatures_suppressed_by`]). +/// run's, see [`shape_segment`]), the OpenType features (each run's apply to its own characters, +/// as features over a range of the one buffer) and the tracking, as long as the tracking keeps or +/// drops standard ligatures alike ([`crate::features::ligatures_suppressed_by`]). fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { // Every field is named, so that a new attribute has to be sorted into one group or the other. let CharStyle { @@ -793,7 +794,9 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { underline: _, strikethrough: _, all_caps, - features, + // Runs with different feature lists shape as one buffer: each run's features apply to its + // own characters only ([`shape_segment`]). + features: _, style_name: _, overprint_fill: _, overprint_stroke: _, @@ -826,7 +829,6 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { && *h_scale == b.h_scale && *v_scale == b.v_scale && *all_caps == b.all_caps - && *features == b.features && *position == b.position && *small_caps == b.small_caps && *proportional_metrics == b.proportional_metrics @@ -978,6 +980,23 @@ struct Shaped { vpal: Vec>, } +/// The features that turn the whole-text features `base` into `own` over the clusters `range`, put +/// after `base` so that they win there: each feature of `own` that `base` lacks or has with +/// another value, and each of `base` that `own` leaves out, back at the value the shaper has +/// without it (on for the default features, off for the rest). The range is given with an +/// inclusive end because harfrust's `Feature::new` takes an exclusive end one cluster short, but +/// applies it as exclusive: a one-byte character would get an empty range. +fn ranged_features(base: &[Feature], own: &[Feature], range: Range, out: &mut Vec) { + const ON_BY_DEFAULT: [&[u8; 4]; 7] = [b"kern", b"liga", b"clig", b"calt", b"apkn", b"vkrn", b"vapk"]; + for f in own.iter().filter(|f| !base.iter().any(|b| b.tag == f.tag && b.value == f.value)) { + out.push(Feature::new(f.tag, f.value, range.start..=range.end)); + } + for b in base.iter().filter(|b| !own.iter().any(|f| f.tag == b.tag)) { + let default = ON_BY_DEFAULT.iter().any(|t| Tag::new(t) == b.tag); + out.push(Feature::new(b.tag, u32::from(default), range.start..=range.end)); + } +} + fn is_mark(c: char) -> bool { use unicode_general_category::{GeneralCategory, get_general_category}; matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark) @@ -1043,6 +1062,23 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe return shape_apart(manual_ends, out); } + // Runs that differ only in their OpenType feature lists are shaped as one buffer: the features + // of each run apply to its own characters only (a feature with a range of clusters, which here + // are source bytes), over those of `base_style`, whose features apply to the whole segment. So + // a feature or an alternate (`salt=2`) on one letter of an Arabic word leaves the joining, + // ligatures and kerning of the whole word to the shaper, as the same text without it. + // `variant` makes a style's features and `base` is what it makes for `base_style`. + let run_features = |variant: &dyn Fn(&CharStyle) -> Vec, base_style: &CharStyle, base: &[Feature]| { + let mut ranged = Vec::new(); + for (r, run_style) in runs.get(source_runs.clone()).unwrap_or_default() { + let (a, b) = (r.start.max(range.start), r.end.min(range.end)); + if run_style.features != base_style.features && a < b { + let own = variant(&CharStyle { features: run_style.features.clone(), ..base_style.clone() }); + ranged_features(base, &own, a..b, &mut ranged); + } + } + ranged + }; // Shape the segment as if all of it were in `st`, with the features `more` on top. let shape_in = |st: &CharStyle, more: &[Feature]| { // A BCP 47 tag is short ASCII; anything else (a file may hold anything) is shaped without one. @@ -1056,8 +1092,10 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let hb_shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); // gid, cluster, x and y advance, x and y offset. - let shape = |feats: &[Feature], direction: Direction| { - let feats: Vec = feats.iter().chain(more).cloned().collect(); + let shape = |variant: &dyn Fn(&CharStyle) -> Vec, direction: Direction| { + let mut feats = variant(st); + feats.extend(run_features(variant, st, &feats)); + feats.extend(more.iter().cloned()); let mut buf = UnicodeBuffer::new(); for (i, c) in text_seg.char_indices() { let cl = (range.start + i) as u32; @@ -1097,13 +1135,13 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe .collect::>() }; let along = if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight }; - *raw = shape(&feats.resolve(st), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect(); + *raw = shape(&|s| feats.resolve(s), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect(); let raw = &*raw; let same_glyphs = |v: &[(u32, u32, i32, i32, i32, i32)]| v.len() == raw.len() && v.iter().zip(raw).all(|(a, b)| a.0 == b.0); if feats.proportional(st) { // `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(&feats.resolve_fixed_width(st), along); + let full = shape(&|s| feats.resolve_fixed_width(s), along); if same_glyphs(&full) { *proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect(); } @@ -1114,7 +1152,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe // and without either tell how it moves each and changes its advance down the column // (y up, advances negative). Which advances `vpal` itself changed comes from setting // them with it alone. - let ttb = |proportional, kerning| shape(&feats.resolve_vertical(st, proportional, kerning), Direction::TopToBottom); + let ttb = |proportional, kerning| shape(&|s| feats.resolve_vertical(s, proportional, kerning), Direction::TopToBottom); let (with, without, alone) = (ttb(true, true), ttb(false, false), ttb(true, false)); if same_glyphs(&with) && same_glyphs(&without) && same_glyphs(&alone) { *vpal = with diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 69ccbfe700..77929bbee2 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -735,6 +735,90 @@ pub fn alternates_font(aalt: bool) -> Option> { Some(builder.build()) } +/// The family of [`joining_alternates_font`]. +pub const JOINING_ALTERNATES_FAMILY: &str = "Jointest Sans"; + +/// Beh, teh, noon and alef: the letters [`joining_alternates_font`] maps. +pub const JOINING_LETTERS: [char; 4] = ['\u{0628}', '\u{062A}', '\u{0646}', '\u{0627}']; + +/// A test-only Arabic font over the bundled Source Sans 3, whose glyphs stand for the positional +/// forms of beh, teh, noon and alef ([`JOINING_LETTERS`]), each a Latin letter's outline: isolated +/// b t n I, initial B T N, medial v u m, final V U M A (alef joins to the right only). Its `arab` +/// `GSUB` has `init`, `medi` and `fina` single substitutions from the isolated glyphs, `salt` +/// alternate substitutions of the medial teh u (w, x, y: three alternates), of the final teh U +/// (W, X), of the isolated teh t (E) and of the medial beh v (z), and `ss01` single substitutions +/// u to w, U to W, t to E and v to z. A set differs by position, as in a font that draws each +/// letter's alternates for the position they join in. Family [`JOINING_ALTERNATES_FAMILY`]. `None` +/// when it can't be built. +pub fn joining_alternates_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + use write_fonts::tables::gsub::{ + AlternateSet, AlternateSubstFormat1, Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList, + }; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + let font = skrifa::FontRef::new(SOURCE_SANS).ok()?; + let charmap = font.charmap(); + let gid = |c: char| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?)); + let family = JOINING_ALTERNATES_FAMILY; + let full = format!("{family} Regular"); + let postscript = format!("{}-Regular", family.replace(' ', "")); + let name = name_table(&[(1, family), (2, "Regular"), (4, &full), (5, "Version 1.000"), (6, &postscript)])?; + let mapping = [(JOINING_LETTERS[0], 'b'), (JOINING_LETTERS[1], 't'), (JOINING_LETTERS[2], 'n'), (JOINING_LETTERS[3], 'I'), (' ', ' ')]; + let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32())))); + let cmap = Cmap::from_mappings(mappings.collect::>>()?).ok()?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + // A single substitution by (glyph, replacement) pairs, sorted by glyph id as coverage needs. + let single = |pairs: &[(char, char)]| { + let mut ids = pairs.iter().map(|(from, to)| Some((gid(*from)?, gid(*to)?))).collect::>>()?; + ids.sort_by_key(|p| p.0.to_u16()); + let from = ids.iter().map(|p| p.0).collect(); + let to = ids.iter().map(|p| p.1).collect(); + Some(SubstitutionLookup::Single(Lookup::new( + Default::default(), + vec![SingleSubst::from(SingleSubstFormat2::new(CoverageFormat1::new(from).into(), to))], + ))) + }; + let alternates = |sets: &[(char, &[char])]| { + let mut ids = sets + .iter() + .map(|(from, set)| Some((gid(*from)?, set.iter().map(|c| gid(*c)).collect::>>()?))) + .collect::>>()?; + ids.sort_by_key(|p| p.0.to_u16()); + let from = ids.iter().map(|p| p.0).collect(); + let sets = ids.into_iter().map(|p| AlternateSet::new(p.1)).collect(); + Some(SubstitutionLookup::Alternate(Lookup::new( + Default::default(), + vec![AlternateSubstFormat1::new(CoverageFormat1::new(from).into(), sets)], + ))) + }; + // Lookups: 0 init, 1 medi, 2 fina, 3 salt, 4 ss01. + let lookups = SubstitutionLookupList::new(vec![ + single(&[('b', 'B'), ('t', 'T'), ('n', 'N')])?, + single(&[('b', 'v'), ('t', 'u'), ('n', 'm')])?, + single(&[('b', 'V'), ('t', 'U'), ('n', 'M'), ('I', 'A')])?, + alternates(&[('u', &['w', 'x', 'y']), ('U', &['W', 'X']), ('t', &['E']), ('v', &['z'])])?, + single(&[('u', 'w'), ('U', 'W'), ('t', 'E'), ('v', 'z')])?, + ]); + // Features sorted by tag, as OpenType requires: fina, init, medi, salt, ss01. + let records: Vec = [(b"fina", 2), (b"init", 0), (b"medi", 1), (b"salt", 3), (b"ss01", 4)] + .into_iter() + .map(|(tag, lookup)| FeatureRecord::new(Tag::new(tag), Feature::new(None, vec![lookup]))) + .collect(); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0, 1, 2, 3, 4])), vec![]))]); + builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`ligature_caret_font`]. pub const LIGATURE_CARET_FAMILY: &str = "Carettest Sans"; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 031d35bdce..b9504b6ac9 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -12,6 +12,7 @@ mod combos; mod devanagari; mod embed; mod fit; +mod glyph_alternates; mod hang; mod inline; mod joiners; diff --git a/crates/text/src/tests/glyph_alternates.rs b/crates/text/src/tests/glyph_alternates.rs new file mode 100644 index 0000000000..edec6de6f0 --- /dev/null +++ b/crates/text/src/tests/glyph_alternates.rs @@ -0,0 +1,293 @@ +//! A feature with a value (`salt=2`) on some characters of an Arabic word: the font's alternate +//! for them, with the word shaped once so that every other letter keeps its joining form and the +//! letters still touch. Read from a font built in code (and, when it is installed, the owner's +//! Kufi Murabba PIIIX). + +use kurbo::Point; +use vectorcraft_doc::{CharStyle, TextRun}; + +use crate::test_fonts::{JOINING_ALTERNATES_FAMILY, JOINING_LETTERS, joining_alternates_font, source_sans_gid}; +use crate::*; + +const BEH: &str = "\u{0628}"; +const TEH: &str = "\u{062A}"; +const NOON: &str = "\u{0646}"; + +fn db() -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + db.add_font(joining_alternates_font().expect("the test font builds")); + db +} + +fn style(family: &str, features: &[&str]) -> CharStyle { + CharStyle { font_family: family.into(), size: 100.0, features: features.iter().map(|f| f.to_string()).collect(), ..CharStyle::default() } +} + +/// `text` as one text object in `family`, each piece with its own features. +fn set(db: &FontDb, family: &str, pieces: &[(&str, &[&str])]) -> Vec { + let mut text = TextObject::point(Point::ZERO, "", style(family, &[])); + text.runs = pieces.iter().map(|(t, features)| TextRun { text: t.to_string(), style: style(family, features), inline: None }).collect(); + // In reading order of the characters, whichever way the line runs. + let mut glyphs = layout(db, &text).glyphs; + glyphs.sort_by_key(|g| g.byte); + glyphs +} + +fn joined(db: &FontDb, pieces: &[(&str, &[&str])]) -> Vec { + set(db, JOINING_ALTERNATES_FAMILY, pieces) +} + +fn gids(glyphs: &[PositionedGlyph]) -> Vec { + glyphs.iter().map(|g| g.gid).collect() +} + +fn g(c: char) -> u32 { + u32::from(source_sans_gid(c).expect("a Source Sans glyph")) +} + +/// The gap between each glyph and the next along the line, whichever way the line runs: 0 when the +/// letters touch. +fn gaps(glyphs: &[PositionedGlyph]) -> Vec { + // `origin` is the pen position at the glyph's left edge, also in right-to-left text. + let mut spans: Vec<(f64, f64)> = glyphs.iter().map(|g| (g.origin.x, g.origin.x + g.advance)).collect(); + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + spans.windows(2).map(|w| w[1].0 - w[0].1).collect() +} + +/// Beh, teh, noon: initial, medial and final in one word, teh drawn with `features`. +fn word(db: &FontDb, teh_features: &[&str]) -> Vec { + joined(db, &[(BEH, &[]), (TEH, teh_features), (NOON, &[])]) +} + +/// Without a feature the word is beh initial, teh medial, noon final. +#[test] +fn the_fixture_word_takes_its_joining_forms() { + let db = db(); + assert_eq!(gids(&joined(&db, &[(&format!("{BEH}{TEH}{NOON}"), &[])])), [g('B'), g('u'), g('M')]); + assert_eq!(JOINING_LETTERS.len(), 4); +} + +/// `salt=N` on the middle letter draws the font's Nth alternate for it, its neighbours keep their +/// initial and final forms (they are not shaped as lone letters), and the letters still touch. +#[test] +fn a_salt_value_on_one_letter_picks_its_alternate_and_keeps_the_word_joined() { + let db = db(); + let plain = word(&db, &[]); + assert_eq!(gids(&plain), [g('B'), g('u'), g('M')]); + let plain_gaps = gaps(&plain); + assert!(plain_gaps.iter().all(|gap| gap.abs() < 1e-6), "the word's glyphs touch: {plain_gaps:?}"); + for (features, middle) in [(&["salt"][..], 'w'), (&["salt=1"][..], 'w'), (&["salt=2"][..], 'x'), (&["salt=3"][..], 'y')] { + let glyphs = word(&db, features); + assert_eq!(gids(&glyphs), [g('B'), g(middle), g('M')], "{features:?}: the alternate between the word's own initial and final forms"); + let gap = gaps(&glyphs); + assert!(gap.iter().all(|gap| gap.abs() < 1e-6), "{features:?}: the letters touch: {gap:?}"); + } + // An alternate number the font has no glyph for, and the feature off, draw the default glyph. + for features in [&["salt=4"][..], &["-salt"][..], &["salt=0"][..], &["salt=2", "-salt"][..], &["ss03"][..]] { + assert_eq!(gids(&word(&db, features)), [g('B'), g('u'), g('M')], "{features:?}"); + } + // The last value of a feature wins. + assert_eq!(gids(&word(&db, &["salt=3", "salt=2"]))[1], g('x')); + // The font's salt lookup comes before its ss01 lookup, so salt takes the glyph first. + assert_eq!(gids(&word(&db, &["salt=2", "ss01"]))[1], g('x')); +} + +/// A feature on the first or the last letter of the word does not split it either. +#[test] +fn a_feature_on_an_end_letter_does_not_break_the_joining() { + let db = db(); + for (pieces, expected) in [ + (vec![(BEH, &["salt=2"][..]), (TEH, &[][..]), (NOON, &[][..])], [g('B'), g('u'), g('M')]), + (vec![(BEH, &[][..]), (TEH, &[][..]), (NOON, &["salt=2"][..])], [g('B'), g('u'), g('M')]), + (vec![(BEH, &["ss01"][..]), (TEH, &["ss01"][..]), (NOON, &["ss01"][..])], [g('B'), g('w'), g('M')]), + ] { + let glyphs = joined(&db, &pieces); + assert_eq!(gids(&glyphs), expected, "{pieces:?}"); + assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-6), "{pieces:?}"); + } +} + +/// The alternates of a letter depend on the form it takes where it stands: the final teh has two, +/// the medial teh three, so `salt=3` is an alternate of one and nothing for the other. +#[test] +fn the_alternate_a_value_picks_depends_on_the_letters_position() { + let db = db(); + let medial = |features: &[&str]| gids(&word(&db, features))[1]; + let final_form = |features: &[&str]| gids(&joined(&db, &[(BEH, &[]), (TEH, features)]))[1]; + assert_eq!((medial(&["salt=3"]), final_form(&["salt=3"])), (g('y'), g('U')), "no third alternate for the final form"); + assert_eq!((medial(&["salt=2"]), final_form(&["salt=2"])), (g('x'), g('X'))); + // A letter alone takes the isolated form's alternate. + assert_eq!(gids(&joined(&db, &[(TEH, &["salt"])])), [g('E')]); + assert_eq!(gids(&joined(&db, &[(TEH, &[])])), [g('t')]); +} + +/// A run with its own feature next to runs of another style in the word shapes with them: the +/// same glyph ids and places as a word typed in one run, when the feature changes nothing. +#[test] +fn runs_that_differ_only_in_features_shape_as_one_word() { + let db = db(); + let one_run = joined(&db, &[(&format!("{BEH}{TEH}{NOON}"), &[])]); + let three_runs = joined(&db, &[(BEH, &[]), (TEH, &["salt=9"]), (NOON, &["-liga"])]); + assert_eq!(gids(&three_runs), gids(&one_run)); + let (a, b): (Vec, Vec) = (one_run.iter().map(|g| g.origin.x).collect(), three_runs.iter().map(|g| g.origin.x).collect()); + assert_eq!(a, b, "the same places"); +} + +/// The Kufi Murabba PIIIX of the font's author, when it is installed: the middle letter of a word +/// takes each of its `salt` alternates and every other letter keeps the glyph it has without one. +#[test] +fn kufi_murabba_alternates_keep_the_word_joined() { + let Some(path) = + std::env::var_os("HOME").map(|h| std::path::Path::new(&h).join("Library/Fonts/KufiMurabbaPIIIX-Regular.ttf")).filter(|p| p.exists()) + else { + return; + }; + let Ok(bytes) = std::fs::read(path) else { return }; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(bytes); + let Some(face) = db.face("Kufi Murabba PIIIX", "Regular") else { return }; + let family = face.family.clone(); + let letters: Vec = "الخوارزمی".chars().collect(); + let text = |pieces: &[(String, Vec<&str>)]| { + let pieces: Vec<(&str, &[&str])> = pieces.iter().map(|(t, f)| (t.as_str(), f.as_slice())).collect(); + set(&db, &family, &pieces) + }; + let plain = text(&[(letters.iter().collect(), vec![])]); + assert_eq!(plain.len(), letters.len(), "one glyph for each letter"); + assert!(gaps(&plain).iter().all(|gap| gap.abs() < 1e-3), "the word joins without a gap: {:?}", gaps(&plain)); + // The medial khah. + let at = 2; + let default = plain[at].gid; + let salt = + face.substitutions(default).into_iter().find(|s| &s.feature == b"salt" && s.glyphs.len() >= 2).expect("the medial khah has salt alternates"); + for (i, alternate) in salt.glyphs.iter().enumerate() { + let value = format!("salt={}", i + 1); + let before: String = letters[..at].iter().collect(); + let after: String = letters[at + 1..].iter().collect(); + let glyphs = text(&[(before, vec![]), (letters[at].to_string(), vec![value.as_str()]), (after, vec![])]); + assert_eq!(glyphs.len(), plain.len(), "{value}"); + assert_eq!(glyphs[at].gid, *alternate, "{value}: the alternate"); + for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != at) { + assert_eq!(a.gid, b.gid, "{value}: the letter {n} keeps its form"); + } + assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{value}: no gap: {:?}", gaps(&glyphs)); + } +} + +/// The `GSUB` reader gives the alternate set of each glyph (position in the set = the feature's +/// value), single substitutions, the feature that reaches a glyph, names and the glyph count. +#[test] +fn the_gsub_reader_lists_alternate_sets_singles_and_the_feature_reaching_a_glyph() { + use crate::{Substitution, SubstitutionKind}; + let db = db(); + let face = db.face(JOINING_ALTERNATES_FAMILY, "Regular").expect("the test face"); + let salt = Substitution { feature: *b"salt", kind: SubstitutionKind::Alternate, glyphs: vec![g('w'), g('x'), g('y')] }; + let ss01 = Substitution { feature: *b"ss01", kind: SubstitutionKind::Single, glyphs: vec![g('w')] }; + assert_eq!(face.substitutions(g('u')), [salt.clone(), ss01.clone()], "salt (an alternate set) before ss01 (a single)"); + assert_eq!(salt.value_for(g('x')), Some(2)); + assert_eq!(salt.value_for(g('u')), None); + assert_eq!((ss01.value_for(g('w')), ss01.value_for(g('x'))), (Some(1), None)); + // Each form of a letter has its own alternates. + let sets = |c| face.substitutions(g(c)).iter().map(|s| (s.feature, s.glyphs.len())).collect::>(); + assert_eq!(sets('U'), [(*b"salt", 2), (*b"ss01", 1)]); + assert_eq!(sets('t'), [(*b"salt", 1), (*b"ss01", 1)]); + assert!(sets('B').is_empty() && sets('M').is_empty(), "the initial and final forms of the others have none"); + assert!(face.substitutions(face.glyph_count()).is_empty(), "past the glyphs"); + // The feature that reaches a glyph no character maps: the listed ones first, then positional forms. + assert_eq!(face.feature_reaching(g('x')), Some(*b"salt")); + assert_eq!(face.feature_reaching(g('w')), Some(*b"salt"), "salt ranks before ss01"); + assert_eq!(face.feature_reaching(g('B')), Some(*b"init")); + assert_eq!(face.feature_reaching(g('m')), Some(*b"medi")); + assert_eq!(face.feature_reaching(g('A')), Some(*b"fina")); + assert_eq!(face.feature_reaching(g('b')), None, "the isolated form is reached by no feature"); + // Names and the glyph count. + let bundled = db.face(FALLBACK_FAMILY, "Regular").expect("the bundled face"); + assert_eq!(face.glyph_count(), bundled.glyph_count()); + assert_eq!(face.glyph_name(g('B')).as_deref(), Some("B")); + assert_eq!(face.glyph_name(face.glyph_count()), None); + // `alternates` is the same reading, for a character. + let listed: Vec<([u8; 4], u32)> = face.alternates('\u{062A}').into_iter().map(|a| (a.feature, a.gid)).collect(); + assert_eq!(listed, [(*b"salt", g('E'))], "the isolated teh"); +} + +/// The alternates of a letter as it stands in its word: the glyph the shaper makes for it there, +/// its alternates, and the feature value that draws each on that letter alone. +#[test] +fn alternates_in_context_list_the_letters_positional_form_and_the_feature_that_draws_each() { + let db = db(); + let text = |teh: &[&str]| { + let mut t = TextObject::point(Point::ZERO, "", style(JOINING_ALTERNATES_FAMILY, &[])); + t.runs = [(BEH, &[][..]), (TEH, teh), (NOON, &[][..])] + .into_iter() + .map(|(s, f)| TextRun { text: s.to_string(), style: style(JOINING_ALTERNATES_FAMILY, f), inline: None }) + .collect(); + t + }; + let teh = BEH.len(); + let found = alternates_in_context(&db, &text(&[]), teh).expect("the medial teh"); + assert_eq!((found.default, found.current), (g('u'), g('u'))); + let listed: Vec<(u32, Option)> = found.alternates.iter().map(|a| (a.gid, a.apply.clone())).collect(); + assert_eq!( + listed, + [(g('w'), Some("salt".to_string())), (g('x'), Some("salt=2".to_string())), (g('y'), Some("salt=3".to_string()))], + "the medial form's three alternates, not the isolated form's one" + ); + // Drawn with salt=2 now: the default is still the form without it. + let now = alternates_in_context(&db, &text(&["salt=2"]), teh).expect("the medial teh"); + assert_eq!((now.default, now.current, now.alternates.len()), (g('u'), g('x'), 3)); + // The first and last letters take their own forms. + let beh = alternates_in_context(&db, &text(&[]), 0).expect("the initial beh"); + assert_eq!((beh.default, beh.alternates.len()), (g('B'), 0)); + let noon = alternates_in_context(&db, &text(&[]), teh + TEH.len()).expect("the final noon"); + assert_eq!((noon.default, noon.alternates.len()), (g('M'), 0)); + assert_eq!(alternates_in_context(&db, &text(&[]), 1), None, "not a character boundary"); + // alternate_changes: the character keeps only the alternate feature it is given. + let own = |f: &[&str]| f.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(alternate_changes(&own(&["ss01", "salt=2", "dlig", "-ss02"]), Some("salt=3")), ["-ss01", "salt=3"]); + assert_eq!(alternate_changes(&own(&["ss01", "salt=2", "dlig"]), None), ["-ss01", "-salt"]); + assert!(alternate_changes(&own(&["salt=2", "dlig"]), Some("salt=2")).is_empty()); + assert_eq!(alternate_changes(&own(&["salt"]), Some("salt=2")), ["salt=2"]); + assert!(alternate_changes(&own(&["dlig"]), None).is_empty()); + assert!(is_alternate_feature("-ss03") && is_alternate_feature("salt=2") && !is_alternate_feature("dlig") && !is_alternate_feature("titl")); +} + +/// A glyph no feature of the style draws is listed without one, and a feature's glyph is found +/// with the feature that draws it. +#[test] +fn a_variant_no_feature_draws_has_no_feature_to_apply() { + use crate::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(alternates_font(true).expect("the test font builds")); + let [u1, u2, u3, u4] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let st = CharStyle { font_family: alternates_family(true).into(), size: 100.0, ..CharStyle::default() }; + let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", st), 1).expect("the a"); + let apply = |gid: u32| found.alternates.iter().find(|a| a.gid == gid).map(|a| a.apply.clone()); + assert_eq!(found.default, face.glyph_for('a')); + assert_eq!(apply(u1), Some(Some("swsh=2".to_string())), "swsh before aalt"); + assert_eq!(apply(u2), Some(Some("ss01".to_string()))); + assert_eq!(apply(u3), Some(Some("swsh".to_string()))); + assert_eq!(apply(u4), Some(Some("ss02".to_string()))); + assert_eq!(apply(face.glyph_for('c')), Some(None), "cv01 has no style tag"); + assert!(face.unencoded_glyphs().contains(&u1) && !face.unencoded_glyphs().contains(&face.glyph_for('a'))); +} + +/// A feature on a one-byte character (Latin) reaches that character: the range of a feature is +/// its source bytes, and harfrust applies a range `a..b` to clusters `a..b - 1` unless the end is +/// inclusive, which would leave a one-byte character out. +#[test] +fn a_feature_on_a_one_byte_character_reaches_it() { + use crate::test_fonts::{alternates_family, alternates_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(alternates_font(true).expect("the test font builds")); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let st = |features: &[&str]| style(alternates_family(true), features); + let mut text = TextObject::point(Point::ZERO, "", st(&[])); + text.runs = vec![ + TextRun { text: "x".into(), style: st(&[]), inline: None }, + TextRun { text: "a".into(), style: st(&["salt"]), inline: None }, + TextRun { text: "y".into(), style: st(&[]), inline: None }, + ]; + let gids: Vec = layout(&db, &text).glyphs.iter().map(|g| g.gid).collect(); + assert_eq!(gids, [face.glyph_for('x'), face.glyph_for('b'), face.glyph_for('y')], "salt draws b for the a alone"); +} diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index 41530de0e9..32333848c7 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -183,9 +183,10 @@ pub(crate) fn swatch_choices(doc: &vectorcraft_doc::Document) -> Value { Value::Array(choices.collect()) } -/// A feature tag without its `-` or `+`. +/// A feature tag without its `-` or `+` prefix and its `=N` value. fn bare(tag: &str) -> &str { - tag.trim_start_matches(['-', '+']) + let tag = tag.trim_start_matches(['-', '+']); + tag.split_once('=').map_or(tag, |(name, _)| name) } /// The OpenType Features fields for a style's `features`: each control is unset unless the list @@ -486,9 +487,14 @@ fn opentype_attrs(d: &Dialog, original: &Map, original_fields: &M } if !unchanged("ot_sets") { let sets = &vectorcraft_text::EXTRA_FEATURES[..20]; + // A set still on keeps the value its tag gives (`ss01=2`). + let before: Vec = tags.iter().filter(|t| sets.contains(&bare(t)) && !t.starts_with('-')).cloned().collect(); tags.retain(|t| !sets.contains(&bare(t))); let listed = d.fields.get("ot_sets").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str); - tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(str::to_string)); + tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(|t| { + let kept = before.iter().rev().find(|b| !t.starts_with('-') && bare(b) == bare(t)); + kept.map_or_else(|| t.to_string(), Clone::clone) + })); } if !tags.is_empty() { attrs.insert("features".into(), json!(tags)); diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index 1f98c74cdf..591524b794 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -44,8 +44,7 @@ impl Flag { fn put(self, f: &mut OtFeatures, on: bool) { match self { Flag::Field(field) => *field(f) = on, - Flag::Extra(tag) if on => f.extra |= extra_bit(tag), - Flag::Extra(tag) => f.extra &= !extra_bit(tag), + Flag::Extra(tag) => f.set_extra(extra_bit(tag), on), } } } @@ -69,13 +68,16 @@ pub(crate) fn figure(f: &OtFeatures) -> usize { fn set_figure(f: &mut OtFeatures, i: usize) { f.tabular_figures = i == 1 || i == 4; f.oldstyle_figures = i >= 3; - f.extra &= !match i { - 1 => extra_bit("pnum"), - 3 => extra_bit("lnum"), - _ => extra_bit("pnum") | extra_bit("lnum"), - }; + f.set_extra( + match i { + 1 => extra_bit("pnum"), + 3 => extra_bit("lnum"), + _ => extra_bit("pnum") | extra_bit("lnum"), + }, + false, + ); if i == 2 { - f.extra |= extra_bit("pnum") | extra_bit("lnum"); + f.set_extra(extra_bit("pnum") | extra_bit("lnum"), true); } } @@ -95,11 +97,7 @@ pub(crate) fn position(f: &OtFeatures) -> Option { /// Default Position turns all four off. fn set_position(f: &mut OtFeatures, i: usize) { for (k, tag) in POSITION_TAGS.iter().enumerate() { - if k + 1 == i { - f.extra |= extra_bit(tag); - } else { - f.extra &= !extra_bit(tag); - } + f.set_extra(extra_bit(tag), k + 1 == i); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0a9bfd9f09..88b80aecde 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,6 +1,6 @@ # Control protocol -> **Last updated:** 2026-10-31 · **Change:** Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). +> **Last updated:** 2026-11-07 · **Change:** text style commands take a feature with a value (`"features": ["salt=2"]`), the font's alternate number, and shape runs that differ only in features as one piece of text; previously: Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). `vectorcraft --control ` listens on `127.0.0.1:` (loopback only). The port must be a number from 1 to 65535 (`--control=` works too), given once; otherwise the app prints an error and exits @@ -922,6 +922,23 @@ handle); a Mesh tool click inside a mesh envelope adds a row and a column. While selected the Control bar shows its controls (Edit Envelope / Edit Contents, warp style, orientation, bend and distortions or mesh rows and columns, Reset, Envelope Options); each runs an `object.envelope.*` command. +## OpenType feature values in text commands + +`text.setRangeStyle`, `text.setStyle` and the character style commands take OpenType features as a list of tags in +`features` (the whole list) or `featureChanges` (changes on top of each character's own): `"dlig"` on, `"-liga"` off. +A feature with alternates (`swsh`, `ss01`–`ss20`, `salt`, `titl`, `pnum`, `lnum`, `sups`, `subs`, `numr`, `dnom`, +`aalt`) also takes `tag=N`, `N` from 0 to 65535, the font's alternate number: `"salt=2"` draws the second alternate of +each glyph in the range that has one. It is the value the shaper gets (an OpenType alternate substitution picks the +glyph at that position of its set), so `"salt"` is `"salt=1"`, and `"-salt"`, `"salt=0"` and a missing tag are off. A +later value replaces an earlier one; `salt=x`, `salt=65536`, `-salt=2` and `liga=2` are refused. The style's +`features` list and the saved file keep the string as typed (`["salt=2"]`), and SVG export writes CSS +`font-feature-settings: "salt" 2`. Runs that differ only in `features` are shaped as one piece of text with each run's +features over its own characters, so one letter of an Arabic word with `salt=2` keeps its neighbours' joining forms. + +```json +{"id":1,"method":"engine.execute","params":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"features":["salt=2"]}}} +``` + ## Shared panel docking The canvas and the panels form one craft-ui docking layout: the protected `canvas` panel beside a dock at the @@ -1017,6 +1034,7 @@ hidden tab (whose frames the browser pauses) answers when it is shown again. | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Documented OpenType feature values in text commands (`"salt=2"`, the font's alternate number) and that runs differing only in features shape as one piece of text. | | 2026-10-31 | minor | Confining file access: on Windows a `:` in a name after the drive (an NTFS alternate data stream) is refused with the device names (#3604). | | 2026-10-25 | minor | Panel docking on craft-ui's layout: `ui.docking`, the floating and dock parameters, native floating windows. | | 2026-10-24 | minor | Documented the brush library panel commands (`window.brushLibrary`, `.other`, `ui.saveBrushLibrary`, `window.userBrushLibrary1`…`10`). | diff --git a/docs/mcp.md b/docs/mcp.md index 1adcf32b1b..238bd07ce8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5726,8 +5726,10 @@ it, as with any style change to one character (`text.setRangeStyle`). The type s ## Alternate glyphs The Glyphs panel's Show › Alternates for Current Selection lists the alternate glyphs of the character selected with -the Type tool (a range's first character, else the one before the caret), with the default glyph first when the font -does not list it, and a double-click on one draws that character with it. `text.glyphAlternates {id, start}` lists them for the character at byte `start`: `{char, font, style, default, +the Type tool (a range's first character, else the one before the caret) as it stands in its word, with the glyph the +shaper makes for it first, and a double-click on one applies the OpenType feature that draws it to that character alone +(see [Alternates as OpenType feature values](#alternates-as-opentype-feature-values)); a glyph no feature draws there is +drawn by `text.setGlyphAlternate`. `text.glyphAlternates {id, start}` lists them for the character at byte `start`: `{char, font, style, default, chosen, alternates: [{gid, feature}]}`, where `default` is the glyph the font's character map gives, `chosen` the glyph drawn now, and `alternates` the glyphs the font's `GSUB` single and alternate substitutions put in place of the default glyph: `aalt` first, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and @@ -5748,6 +5750,36 @@ system). Choosing the glyph a character already draws is not an edit. Character {"name":"run_command","arguments":{"command":"text.setGlyphAlternate","params":{"id":7,"start":2,"gid":1043}}} ``` +### Alternates as OpenType feature values + +`text.setGlyphAlternate`'s choice swaps a glyph id into one character and shapes that character apart from its neighbours. +A feature with a value keeps the text real and joined: `features`, `featureChanges` (`text.setRangeStyle`, +`text.setStyle`, `charStyle.*`) take `tag=N` for `swsh`, `ss01` to `ss20`, `salt`, `titl`, `pnum`, `lnum`, `sups`, +`subs`, `numr`, `dnom` and `aalt`, `N` from 0 to 65535, and the style's `features` list keeps the string: +`"salt=2"` draws the font's second alternate of every glyph in the run that has one (the OpenType alternate +substitution picks the glyph at that position of its set; a glyph without one, or with fewer alternates, keeps the +glyph it has). A plain `"salt"` is `"salt=1"` and is written so; `"salt=0"`, `"-salt"` and leaving it out are off. The +next value for a feature replaces the last one, in `features` and in `featureChanges`, and a tag the build does not know +(`salt=x`, `salt=65536`, `-salt=2`, `liga=2`) is refused with the unknown-feature error. SVG export writes the value as +CSS `font-feature-settings: "salt" 2`. + +Runs that differ only in their `features` are shaped together as one piece of text, each run's features over its own +characters: styling the middle letter of an Arabic word with `salt=2` leaves every other letter its initial, medial or +final form and the letters still join, as the same word typed with no style change. Runs that differ in font, size, +scale, case, language, script or direction are still shaped apart. + +`text.glyphAlternates` lists a character's alternates by the glyph the font's character map gives it; the Glyphs panel +instead lists those of the glyph the shaper makes for the character in its word (a medial letter's medial form), finds +for each the feature value that draws it there, and on a double-click runs one `text.setRangeStyle` on exactly that +character: `featureChanges` turns its other alternate features (`salt`, `aalt`, `swsh`, `ss01`–`ss20`) off and sets the +value (`["-ss01", "salt=2"]`), and `glyphAlternate: null` takes away a glyph choice that would otherwise keep drawing +its own glyph (`text.setRangeStyle` takes it; any other value is refused). It is one undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"features":["salt=2"]}}} +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":7,"start":4,"end":6,"featureChanges":["-salt"]}}} +``` + ## New type in a Japanese interface While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em From 7e4594739910cdaf912e902d97eadae598b75e17 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:19:24 +0430 Subject: [PATCH 02/79] Glyphs: Alternates for Current Selection applies a feature value to one letter; Entire Font lists unencoded glyphs The grid lists the alternates of the glyph the shaper makes for the selected character in its word, and a double-click applies the feature that draws it (salt=2) to that character alone in one undo step, so an Arabic word stays joined. A variant no feature draws keeps the replacement-glyph fallback, with a tooltip. Entire Font adds glyphs no character maps, with name or gid and the feature reaching them. i18n rows in every catalog; parity docs updated (#5514). --- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/glyphs.rs | 330 +++++++++++++++++++++++----- docs/gaps.md | 5 +- docs/target-app-parity.md | 4 +- docs/type-parity.md | 4 +- 31 files changed, 335 insertions(+), 62 deletions(-) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f6a8debdc7..dc53b5b246 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -846,6 +846,8 @@ font-technology Variable Променливи The selected character's font is not available. Шрифтът на избрания знак не е наличен. No alternates for the selected character. Няма алтернативи за избрания знак. Double-click to replace Двойно щракване за замяна + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Нито една OpenType функция не рисува този глиф тук, затова той заменя глифа на знака и може да не се свързва със съседните знаци. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Този глиф няма знак, затова не може да се вмъкне. Използвайте „Алтернативи за текущата селекция“, за да го приложите. Double-click to insert Двойно щракване за вмъкване Double-click to rename Двойно щракване за преименуване Double-click to target the contents Двойно щракване за насочване към съдържанието diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a8db1ebeaf..a4c34938b5 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1554,6 +1554,8 @@ digits Malayalam Malajalam The selected character's font is not available. Font odabranog znaka nije dostupan. No alternates for the selected character. Nema alternativa za odabrani znak. Double-click to replace Dvostruki klik za zamjenu + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nijedna OpenType funkcija ne crta ovaj glif ovdje, pa on zamjenjuje glif znaka i možda se neće spajati sa susjednim znakovima. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ovaj glif nema znak, pa se ne može umetnuti. Koristite „Alternative za trenutni odabir“ da ga primijenite. Double-click to insert Dvostruki klik za umetanje Entire Font Cijeli font glyph {id} glif {id} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3898a677f4..7af14f8aab 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1208,6 +1208,8 @@ The selected character's font is not available. El tipus de lletra del caràcter seleccionat no està disponible. No alternates for the selected character. No hi ha alternatives per al caràcter seleccionat. Double-click to replace Feu doble clic per substituir + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Cap funció OpenType dibuixa aquest glif aquí, de manera que substitueix el glif del caràcter i pot no unir-se als caràcters veïns. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Aquest glif no té cap caràcter, de manera que no es pot inserir. Feu servir «Alternatives per a la selecció actual» per aplicar-lo. Double-click to insert Feu doble clic per inserir Double-click to rename Feu doble clic per canviar el nom Double-click to target the contents Feu doble clic per apuntar al contingut diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index eff1d2d343..93c816d1c6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1578,6 +1578,8 @@ digits Malayalam Malajálamské The selected character's font is not available. Písmo vybraného znaku není k dispozici. No alternates for the selected character. Vybraný znak nemá žádné alternativy. Double-click to replace Dvojklikem nahradíte + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Žádná funkce OpenType tento glyf zde nekreslí, proto nahradí glyf znaku a nemusí se spojit se sousedními znaky. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Tento glyf nemá žádný znak, takže ho nelze vložit. Použijte „Alternativy pro aktuální výběr“. Double-click to insert Dvojklikem vložíte Entire Font Celé písmo glyph {id} glyf {id} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index abfe581e70..4917bd8168 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1488,6 +1488,8 @@ digits Malayalam Malayalam The selected character's font is not available. Skrifttypen for det markerede tegn er ikke tilgængelig. No alternates for the selected character. Ingen alternativer til det markerede tegn. Double-click to replace Dobbeltklik for at erstatte + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion tegner dette glyf her, så det erstatter tegnets glyf og forbindes måske ikke med nabotegnene. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Dette glyf har intet tegn og kan derfor ikke indsættes. Brug „Alternativer til den aktuelle markering“ for at anvende det. Double-click to insert Dobbeltklik for at indsætte Entire Font Hele skrifttypen glyph {id} glyf {id} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4a4142d883..678162e111 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -840,6 +840,8 @@ font-technology Variable Variabel The selected character's font is not available. Die Schrift des ausgewählten Zeichens ist nicht verfügbar. No alternates for the selected character. Keine Alternativen für das ausgewählte Zeichen. Double-click to replace Doppelklicken zum Ersetzen + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Keine OpenType-Funktion zeichnet diese Glyphe hier, daher ersetzt sie die Glyphe des Zeichens und verbindet sich möglicherweise nicht mit den Nachbarzeichen. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Diese Glyphe hat kein Zeichen und lässt sich daher nicht einfügen. Verwenden Sie „Alternativen für aktuelle Auswahl“, um sie anzuwenden. Double-click to insert Doppelklicken zum Einfügen Double-click to rename Doppelklicken zum Umbenennen Double-click to target the contents Doppelklicken, um den Inhalt als Ziel auszuwählen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6b2a022d58..3e5d56de29 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -819,6 +819,8 @@ font-technology Variable Μεταβλητές The selected character's font is not available. Η γραμματοσειρά του επιλεγμένου χαρακτήρα δεν είναι διαθέσιμη. No alternates for the selected character. Δεν υπάρχουν εναλλακτικοί για τον επιλεγμένο χαρακτήρα. Double-click to replace Διπλό κλικ για αντικατάσταση + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Καμία λειτουργία OpenType δεν σχεδιάζει αυτόν τον γλύφο εδώ, οπότε αντικαθιστά τον γλύφο του χαρακτήρα και μπορεί να μην συνδέεται με τους γειτονικούς χαρακτήρες. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Αυτός ο γλύφος δεν έχει χαρακτήρα και δεν μπορεί να εισαχθεί. Χρησιμοποιήστε τους «Εναλλακτικούς για την τρέχουσα επιλογή» για να τον εφαρμόσετε. Double-click to insert Διπλό κλικ για εισαγωγή Double-click to rename Διπλό κλικ για μετονομασία Double-click to target the contents Διπλό κλικ για επιλογή του περιεχομένου ως στόχου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6ccd50b466..5a262f20d5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -861,6 +861,8 @@ font-technology Variable Variables The selected character's font is not available. La fuente del carácter seleccionado no está disponible. No alternates for the selected character. No hay alternativas para el carácter seleccionado. Double-click to replace Doble clic para reemplazar + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ninguna función OpenType dibuja este glifo aquí, así que sustituye el glifo del carácter y puede que no se una a los caracteres vecinos. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Este glifo no tiene carácter, así que no se puede insertar. Usa «Alternativas para la selección actual» para aplicarlo. Double-click to insert Doble clic para insertar Double-click to rename Doble clic para cambiar el nombre Double-click to target the contents Doble clic para seleccionar el contenido como destino diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3cf8607c0b..202e008e12 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1551,6 +1551,8 @@ digits Malayalam Malajalam The selected character's font is not available. Valitun merkin fontti ei ole saatavilla. No alternates for the selected character. Valitulla merkillä ei ole vaihtoehtoja. Double-click to replace Korvaa kaksoisnapsauttamalla + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Mikään OpenType-ominaisuus ei piirrä tätä glyfiä tässä, joten se korvaa merkin glyfin eikä välttämättä liity viereisiin merkkeihin. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Tällä glyfillä ei ole merkkiä, joten sitä ei voi lisätä. Käytä toimintoa ”Nykyisen valinnan vaihtoehdot” sen käyttöön. Double-click to insert Lisää kaksoisnapsauttamalla Entire Font Koko fontti glyph {id} merkki {id} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 67a7cb29c8..f93b915232 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -864,6 +864,8 @@ font-technology Variable Variables The selected character's font is not available. La police du caractère sélectionné n’est pas disponible. No alternates for the selected character. Aucune variante pour le caractère sélectionné. Double-click to replace Double-cliquez pour remplacer + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Aucune fonction OpenType ne dessine ce glyphe ici : il remplace le glyphe du caractère et peut ne pas se lier aux caractères voisins. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ce glyphe n’a pas de caractère et ne peut donc pas être inséré. Utilisez « Variantes de la sélection » pour l’appliquer. Double-click to insert Double-cliquez pour insérer Double-click to rename Double-cliquez pour renommer Double-click to target the contents Double-cliquez pour cibler le contenu diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index e1af82a3a9..5b8b11cc50 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1344,6 +1344,8 @@ Double-click to insert सम्मिलित करने के लिए डबल क्लिक करें Double-click to rename नाम बदलने के लिए डबल क्लिक करें Double-click to replace प्रतिस्थापित करने के लिए डबल क्लिक करें + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. यहाँ कोई OpenType फ़ीचर इस ग्लिफ़ को नहीं बनाता, इसलिए यह कैरेक्टर के ग्लिफ़ की जगह लेता है और पड़ोसी कैरेक्टरों से जुड़ नहीं भी सकता। + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. इस ग्लिफ़ का कोई कैरेक्टर नहीं है, इसलिए इसे सम्मिलित नहीं किया जा सकता। इसे लागू करने के लिए “वर्तमान सिलेक्शन के लिए ऑप्शन” का उपयोग करें। Double-click to target the contents सामग्री को लक्षित करने के लिए डबल क्लिक करें Down (right reading) नीचे (दाएं पढ़ने) Download डाउनलोड diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 82f75da721..516ccf02de 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1114,6 +1114,8 @@ font-technology Variable Változó The selected character's font is not available. A kijelölt karakter betűtípusa nem érhető el. No alternates for the selected character. A kijelölt karakternek nincsenek változatai. Double-click to replace Csere dupla kattintással + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Itt egyetlen OpenType-funkció sem rajzolja meg ezt a glifát, ezért a karakter glifáját váltja fel, és előfordulhat, hogy nem kapcsolódik a szomszédos karakterekhez. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ennek a glifának nincs karaktere, ezért nem szúrható be. Az alkalmazásához használd az „Az aktuális kijelölés változatai” nézetet. Double-click to insert Beszúrás dupla kattintással Double-click to rename Dupla kattintással átnevezhető Double-click to target the contents Dupla kattintással a tartalom lesz a cél diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 7230b6bfa5..6b488a850e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1488,6 +1488,8 @@ digits Malayalam Malayalam The selected character's font is not available. Font karakter yang dipilih tidak tersedia. No alternates for the selected character. Tidak ada alternatif untuk karakter yang dipilih. Double-click to replace Klik ganda untuk mengganti + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Tidak ada fitur OpenType yang menggambar glif ini di sini, jadi glif ini menggantikan glif karakter dan mungkin tidak menyambung dengan karakter di sebelahnya. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Glif ini tidak punya karakter, sehingga tidak bisa disisipkan. Gunakan “Alternatif untuk Pilihan Saat Ini” untuk menerapkannya. Double-click to insert Klik ganda untuk menyisipkan Entire Font Seluruh font glyph {id} glif {id} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 723fa4ab08..433847dbf1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -861,6 +861,8 @@ font-technology Variable Variabili The selected character's font is not available. Il font del carattere selezionato non è disponibile. No alternates for the selected character. Nessuna alternativa per il carattere selezionato. Double-click to replace Fai doppio clic per sostituire + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nessuna funzione OpenType disegna questo glifo qui: sostituisce il glifo del carattere e potrebbe non unirsi ai caratteri vicini. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Questo glifo non ha un carattere e non può essere inserito. Usa «Alternative per la selezione corrente» per applicarlo. Double-click to insert Fai doppio clic per inserire Double-click to rename Fai doppio clic per rinominare Double-click to target the contents Fai doppio clic per impostare il contenuto come destinazione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8bb1012693..e0d8f5c2e8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1424,6 +1424,8 @@ digits Malayalam マラヤーラム数字 The selected character's font is not available. 選択した文字のフォントを使用できません。 No alternates for the selected character. 選択した文字には異体字がありません。 Double-click to replace ダブルクリックで置換 + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. このグリフを表示する OpenType フィーチャーがないため、文字のグリフが置き換わり、前後の文字とつながらないことがあります。 + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. このグリフには対応する文字がないため、挿入できません。適用するには「選択文字の異体字」を使用してください。 Double-click to insert ダブルクリックで挿入 Double-click to rename ダブルクリックで名前を変更 Double-click to target the contents ダブルクリックで内容をターゲット diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 343b415a48..9e473e2f3d 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1414,6 +1414,8 @@ digits Malayalam მალაიალამური The selected character's font is not available. მონიშნული სიმბოლოს შრიფტი მიუწვდომელია. No alternates for the selected character. მონიშნულ სიმბოლოს ალტერნატივები არ აქვს. Double-click to replace ორჯერ დაწკაპეთ ჩასანაცვლებლად + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. აქ არც ერთი OpenType ფუნქცია არ ხატავს ამ გლიფს, ამიტომ ის ანაცვლებს სიმბოლოს გლიფს და შეიძლება არ დაუკავშირდეს მეზობელ სიმბოლოებს. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. ამ გლიფს სიმბოლო არ აქვს, ამიტომ მისი ჩასმა შეუძლებელია. გამოსაყენებლად გამოიყენეთ „მიმდინარე მონიშვნის ალტერნატივები“. Double-click to insert ორჯერ დაწკაპეთ ჩასასმელად Entire Font მთელი შრიფტი glyph {id} გლიფი {id} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f4cc45b827..8333201b82 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1414,6 +1414,8 @@ digits Malayalam 말라얄람어 The selected character's font is not available. 선택한 문자의 글꼴을 사용할 수 없습니다. No alternates for the selected character. 선택한 문자의 대체 글리프가 없습니다. Double-click to replace 더블클릭하여 바꾸기 + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 이 글리프를 그리는 OpenType 기능이 없으므로 문자의 글리프를 대체하며 이웃 문자와 연결되지 않을 수 있습니다. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. 이 글리프에는 문자가 없어 삽입할 수 없습니다. 적용하려면 ‘현재 선택 항목의 대체 글리프’를 사용하세요. Double-click to insert 더블클릭하여 삽입 Double-click to rename 더블클릭하여 이름 변경 Double-click to target the contents 더블클릭하여 내용 선택 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2e8d6f34c3..8fb204e2d6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1502,6 +1502,8 @@ digits Malayalam Malayalam The selected character's font is not available. Het lettertype van het geselecteerde teken is niet beschikbaar. No alternates for the selected character. Geen alternatieven voor het geselecteerde teken. Double-click to replace Dubbelklik om te vervangen + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Geen enkele OpenType-functie tekent deze glyph hier, dus hij vervangt de glyph van het teken en sluit mogelijk niet aan op de buurtekens. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Deze glyph heeft geen teken en kan dus niet worden ingevoegd. Gebruik ‘Alternatieven voor huidige selectie’ om hem toe te passen. Double-click to insert Dubbelklik om in te voegen Entire Font Volledig lettertype glyph {id} glyph {id} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c9e1256c3e..86af456b81 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1491,6 +1491,8 @@ digits Malayalam Malajalam The selected character's font is not available. Czcionka zaznaczonego znaku jest niedostępna. No alternates for the selected character. Brak alternatyw dla zaznaczonego znaku. Double-click to replace Kliknij dwukrotnie, aby zastąpić + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Żadna funkcja OpenType nie rysuje tutaj tego glifu, więc zastępuje on glif znaku i może nie łączyć się z sąsiednimi znakami. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ten glif nie ma znaku, więc nie można go wstawić. Użyj opcji „Alternatywy bieżącego zaznaczenia”, aby go zastosować. Double-click to insert Kliknij dwukrotnie, aby wstawić Entire Font Cała czcionka glyph {id} glif {id} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 062b7d88b4..5e5a4396cf 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -771,6 +771,8 @@ The selected character's font is not available. A fonte do caractere selecionado não está disponível. No alternates for the selected character. Nenhuma alternativa para o caractere selecionado. Double-click to replace Clique duplo para substituir + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nenhum recurso OpenType desenha este glifo aqui, então ele substitui o glifo do caractere e pode não se unir aos caracteres vizinhos. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Este glifo não tem caractere e não pode ser inserido. Use “Alternativas para a seleção atual” para aplicá-lo. Double-click to insert Clique duplo para inserir Double-click to rename Clique duplo para renomear Double-click to target the contents Clique duplo para direcionar o conteúdo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a83d9edf36..6de9bb6713 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -859,6 +859,8 @@ font-technology Variable Вариативные The selected character's font is not available. Шрифт выделенного символа недоступен. No alternates for the selected character. У выделенного символа нет альтернатив. Double-click to replace Дважды щёлкните для замены + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ни одна функция OpenType не рисует здесь этот глиф, поэтому он заменяет глиф символа и может не соединяться с соседними символами. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. У этого глифа нет символа, поэтому его нельзя вставить. Чтобы применить его, используйте «Альтернативы для текущего выделения». Double-click to insert Дважды щёлкните для вставки Double-click to rename Дважды щёлкните для переименования Double-click to target the contents Дважды щёлкните, чтобы выбрать содержимое как объект diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 780d23beae..87735c99d9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -848,6 +848,8 @@ font-technology Variable Variabla The selected character's font is not available. Typsnittet för det markerade tecknet är inte tillgängligt. No alternates for the selected character. Inga alternativ för det markerade tecknet. Double-click to replace Dubbelklicka för att ersätta + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion ritar den här glyfen här, så den ersätter tecknets glyf och ansluter kanske inte till grannteckenen. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Den här glyfen har inget tecken och kan därför inte infogas. Använd ”Alternativ för aktuell markering” för att tillämpa den. Double-click to insert Dubbelklicka för att infoga Double-click to rename Dubbelklicka för att byta namn Double-click to target the contents Dubbelklicka för att välja innehållet som mål diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 55a9cdc737..13c68896dd 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -840,6 +840,8 @@ font-technology Variable Değişken The selected character's font is not available. Seçili karakterin yazı tipi kullanılamıyor. No alternates for the selected character. Seçili karakter için alternatif yok. Double-click to replace Değiştirmek için çift tıklayın + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Hiçbir OpenType özelliği bu glifi burada çizmez; bu yüzden karakterin glifinin yerine geçer ve komşu karakterlere bağlanmayabilir. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Bu glifin karakteri yok, bu yüzden eklenemez. Uygulamak için “Geçerli Seçim için Alternatifler”i kullanın. Double-click to insert Eklemek için çift tıklayın Double-click to rename Yeniden adlandırmak için çift tıklayın Double-click to target the contents İçeriği hedeflemek için çift tıklayın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bbfc332f91..6a8756e775 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -855,6 +855,8 @@ font-technology Variable Варіативні The selected character's font is not available. Шрифт виділеного символу недоступний. No alternates for the selected character. Виділений символ не має альтернатив. Double-click to replace Двічі клацніть для заміни + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Жодна функція OpenType не малює тут цей гліф, тому він замінює гліф символу й може не з’єднуватися із сусідніми символами. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. У цього гліфа немає символу, тому його не можна вставити. Щоб застосувати його, скористайтеся «Альтернативи для поточного виділення». Double-click to insert Двічі клацніть для вставки Double-click to rename Двічі клацніть для перейменування Double-click to target the contents Двічі клацніть, щоб вибрати вміст як об’єкт diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 47915fb171..1e1061a79b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -999,6 +999,8 @@ font-technology Variable Biến thiên The selected character's font is not available. Phông chữ của ký tự đã chọn không khả dụng. No alternates for the selected character. Ký tự đã chọn không có glyph thay thế. Double-click to replace Bấm đúp để thay thế + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Không có tính năng OpenType nào vẽ glyph này ở đây, nên nó thay glyph của ký tự và có thể không nối với các ký tự bên cạnh. + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Glyph này không có ký tự nên không thể chèn. Hãy dùng “Glyph thay thế cho vùng chọn hiện tại” để áp dụng. Double-click to insert Bấm đúp để chèn Double-click to rename Bấm đúp để đổi tên Double-click to target the contents Bấm đúp để chọn nội dung diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e646c956bb..ae82c23144 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -803,6 +803,8 @@ The selected character's font is not available. 所选字符的字体不可用。 No alternates for the selected character. 所选字符没有替代字。 Double-click to replace 双击以替换 + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 没有任何 OpenType 功能会在此处绘制该字形,因此它会替换该字符的字形,并且可能无法与相邻字符连接。 + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. 此字形没有对应的字符,无法插入。请使用“当前所选字符的替代字”来应用它。 Double-click to insert 双击以插入 Double-click to rename 双击以重命名 Double-click to target the contents 双击以指定内容 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 468ad851aa..841cff8558 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -823,6 +823,8 @@ font-technology Variable 可變 The selected character's font is not available. 選取字元的字體無法使用。 No alternates for the selected character. 選取的字元沒有替代字。 Double-click to replace 按兩下以取代 + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 沒有任何 OpenType 功能會在此處繪製該字形,因此它會取代該字元的字形,且可能無法與相鄰字元連接。 + This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. 此字形沒有對應的字元,無法插入。請使用「目前選取字元的替代字」來套用。 Double-click to insert 按兩下以插入 Double-click to rename 按兩下以重新命名 Double-click to target the contents 按兩下以指定內容 diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 5c07441bee..b4d0ab6904 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -1,8 +1,10 @@ //! Glyphs panel: every character the current font maps (its cmap), drawn from the font's own -//! outlines. Double-click inserts the character at the Type tool's caret (or appends it to the -//! selected text objects). The character selected with the Type tool is highlighted in the grid. -//! Show › Alternates for Current Selection lists that character's alternate glyphs instead, and a -//! double-click draws the character with one of them. +//! outlines; Show › Entire Font adds the glyphs no character maps, by glyph id. Double-click +//! inserts the character at the Type tool's caret (or appends it to the selected text objects). +//! The character selected with the Type tool is highlighted in the grid. +//! Show › Alternates for Current Selection lists that character's alternate glyphs instead (as it +//! stands in its word), and a double-click applies the OpenType feature that draws one to that +//! character alone, so the text stays real text that joins its neighbors. use std::collections::HashMap; @@ -240,9 +242,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }; let chars: Vec<(char, u32)> = face.chars().into_iter().filter(|(c, _)| (lo..=hi).contains(&(*c as u32)) && !c.is_control() && !c.is_whitespace()).collect(); + // Entire Font also lists the glyphs no character maps (positional forms, alternates, + // ligatures) by glyph id, after the characters. + let unencoded = if subset == 0 { face.unencoded_glyphs() } else { Default::default() }; + let total = chars.len() + unencoded.len(); let cell = CELL_STEPS[cell_index(app)]; let cols = ((w / cell).floor() as usize).max(1); - let rows = chars.len().div_ceil(cols); + let rows = total.div_ceil(cols); let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32; let list_h = (ui.available_height() - 64.0).max(cell * 3.0); let hover_id = egui::Id::unique("gl-hover"); @@ -264,9 +270,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for r in range.clone() { ui.horizontal(|ui| { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); - for &(c, gid) in chars.iter().skip(r * cols).take(cols) { + for k in r * cols..((r + 1) * cols).min(total) { + let (c, gid) = match chars.get(k) { + Some(&(c, gid)) => (Some(c), gid), + None => (None, unencoded.get(k - chars.len()).copied().unwrap_or_default()), + }; let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click()); - let chosen = selected == Some(c); + let chosen = c.is_some() && selected == c; if resp.hovered() { ui.painter().rect_filled(rect, 2, t.hover); ui.ctx().data_mut(|d| d.insert_temp(hover_id, (c, gid))); @@ -277,9 +287,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().rect_stroke(rect.shrink(0.5), 0, edge, StrokeKind::Inside); let tex = glyph_texture(ui.ctx(), &face, gid, px); ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong); - let resp = resp.on_hover_text(format!("U+{:04X} {c} (glyph {gid})\n{}", c as u32, tl!("Double-click to insert"))); + let tip = match c { + Some(c) => format!("U+{:04X} {c} (glyph {gid})\n{}", c as u32, tl!("Double-click to insert")), + None => unencoded_tip(&face, gid), + }; + let resp = resp.on_hover_text(tip); if resp.double_clicked() { - insert(app, ui.ctx(), c); + match c { + Some(c) => insert(app, ui.ctx(), c), + None => app.status(tl!( + "This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it." + )), + } } } }); @@ -288,16 +307,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); ui.ctx().data_mut(|d| d.insert_temp(follow_id, (selected, shown.inner))); ui.add_space(4.0); - let info = ui.ctx().data(|d| d.get_temp::<(char, u32)>(hover_id)); + let info = ui.ctx().data(|d| d.get_temp::<(Option, u32)>(hover_id)); + let count = crate::i18n::tn(total as u64, "{n} glyph", "{n} glyphs"); ui.label( egui::RichText::new(match info { - Some((c, g)) => format!( - "U+{:04X} {} · {}", - c as u32, - crate::i18n::fmt(tl!("glyph {id}"), &[("id", &g.to_string())]), - crate::i18n::tn(chars.len() as u64, "{n} glyph", "{n} glyphs") - ), - None => crate::i18n::tn(chars.len() as u64, "{n} glyph", "{n} glyphs"), + Some((c, g)) => { + let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &g.to_string())]); + match c { + Some(c) => format!("U+{:04X} {glyph} · {count}", c as u32), + None => format!("{glyph} · {count}"), + } + } + None => count, }) .size(11.0) .color(t.text_dim), @@ -340,11 +361,36 @@ fn font_row(app: &mut VectorcraftApp, ui: &mut Ui, family: &str, style: &str, w: }); } +/// One cell of Show › Alternates for Current Selection: a glyph, the feature that lists it, and the +/// feature tag that draws it on the selected character in its word (`salt=2`), when one does. +struct Variant { + gid: u32, + feature: [u8; 4], + apply: Option, + /// The glyph the shaper makes for the character with none of its own alternate features. + default: bool, +} + +/// What a double-click on a variant does to the selected character. +enum Pick { + /// Back to the default glyph: its alternate features off, a glyph choice taken away. + Default, + /// A feature that draws the variant: the text stays real Unicode and its word stays joined. + Feature(String), + /// No feature draws it: it replaces the character's glyph (`text.setGlyphAlternate`), which is + /// shaped apart from its neighbors. + Glyph(u32), +} + /// The grid of Show › Alternates for Current Selection: the alternate glyphs of the Type tool's -/// selected character in the face its text is set in, whatever font the panel browses -/// ([`vectorcraft_text::FontFace::alternates`]), with that face's name under the grid. The default -/// glyph comes first when the font does not list it. The glyph the character is drawn with is -/// highlighted. A double-click draws the character with the glyph (`text.setGlyphAlternate`). +/// selected character as it stands in its word ([`vectorcraft_text::alternates_in_context`]: a +/// medial letter lists its medial form's alternates), in the face the text is set in, whatever font +/// the panel browses, with that face's name under the grid. The glyph the shaper makes for the +/// character with none of its own alternate features comes first, and the glyph the character is +/// drawn with is highlighted. A double-click applies the OpenType feature that draws the glyph to +/// that character alone (`text.setRangeStyle`, `salt=2`), as one undo step, so the text stays real +/// text that joins its neighbors. A glyph no feature draws there replaces the character's glyph +/// instead (`text.setGlyphAlternate`), and its tooltip says that it may not join. fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { let t = Tokens::get(ui.ctx()); let db = FontDb::global(); @@ -360,13 +406,25 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { return note(ui, tl!("The selected character's font is not available.")); }; let (id, byte, ch) = (target.id, target.byte, target.ch); - let (list, drawn) = alternates_and_drawn(app, ui, &face, id, byte, ch); + let (features, has_choice) = (target.style.features.clone(), target.style.glyph_alternate.is_some()); + let found = alternates_found(app, ui, id, byte, face.id()); let font = format!("{} {}", face.family, face.style); - if list.is_empty() { + let Some(found) = found.filter(|f| !f.alternates.is_empty()) else { note(ui, tl!("No alternates for the selected character.")); ui.label(egui::RichText::new(font).size(11.0).color(t.text_dim)); return; - } + }; + // The glyphs are drawn from the face that supplied the character's glyph (a fallback face for + // a character the text's font lacks). + let face = db.face_by_id(found.font_id).unwrap_or(face); + let drawn = Some(found.current); + let mut list = vec![Variant { gid: found.default, feature: [0; 4], apply: None, default: true }]; + list.extend(found.alternates.iter().filter(|a| a.gid != found.default).map(|a| Variant { + gid: a.gid, + feature: a.feature, + apply: a.apply.clone(), + default: false, + })); let cell = CELL_STEPS[cell_index(app)]; let cols = ((w / cell).floor() as usize).max(1); let rows = list.len().div_ceil(cols); @@ -381,7 +439,7 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); for a in list.iter().skip(r * cols).take(cols) { let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click()); - let current = drawn.is_some_and(|(font_id, gid)| font_id == face.id() && a.gid == gid); + let current = drawn == Some(a.gid); if resp.hovered() { ui.painter().rect_filled(rect, 2, t.hover); } else if current { @@ -392,10 +450,25 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { let tex = glyph_texture(ui.ctx(), &face, a.gid, px); ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong); let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &a.gid.to_string())]); - let glyph = if a.feature == [0; 4] { glyph } else { format!("{glyph} ({})", String::from_utf8_lossy(&a.feature)) }; - let resp = resp.on_hover_text(format!("{glyph}\n{}", tl!("Double-click to replace"))); + let glyph = match (&a.apply, a.default) { + (_, true) => glyph, + (Some(tag), _) => format!("{glyph} ({tag})"), + (None, _) => format!("{glyph} ({})", String::from_utf8_lossy(&a.feature)), + }; + let mut tip = format!("{glyph}\n{}", tl!("Double-click to replace")); + if !a.default && a.apply.is_none() { + tip.push('\n'); + tip.push_str(tl!( + "No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors." + )); + } + let resp = resp.on_hover_text(tip); if resp.double_clicked() { - pick = Some(a.gid); + pick = Some(match (&a.apply, a.default) { + (_, true) => Pick::Default, + (Some(tag), _) => Pick::Feature(tag.clone()), + (None, _) => Pick::Glyph(a.gid), + }); } } }); @@ -404,28 +477,61 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { ui.add_space(4.0); let count = crate::i18n::tn(list.len() as u64, "{n} glyph", "{n} glyphs"); ui.label(egui::RichText::new(format!("{font} · {count}")).size(11.0).color(t.text_dim)); - if let Some(gid) = pick { + if let Some(pick) = pick { super::character::end_typing(app); - if let Err(e) = app.run("text.setGlyphAlternate", json!({"id": id.0, "start": byte, "gid": gid})) { - app.status(e.to_string()); - } + apply_alternate(app, id, (byte, byte + ch.len_utf8()), &features, has_choice, pick); } } -/// The alternates of `ch` in `face` and the face id and glyph the text object `id` draws the -/// character at `byte` with. Both are kept in egui's temporary data until the document, the -/// character or the face changes, as [`super::character`] keeps the kerning at the caret. -fn alternates_and_drawn( - app: &VectorcraftApp, - ui: &Ui, - face: &vectorcraft_text::FontFace, - id: NodeId, - byte: usize, - ch: char, -) -> (Vec, Option<(u32, u32)>) { - type Found = (Vec, Option<(u32, u32)>); +/// Do what a double-click on a variant says to the character at `bytes` of text `id`, whose style +/// has `features` and, when `has_choice`, a glyph choice: one command, so one undo step. A feature +/// goes through `text.setRangeStyle` on exactly the character (its other alternate features are +/// turned off, a glyph choice is taken away); a glyph no feature draws through +/// `text.setGlyphAlternate`. The default glyph, or a variant the character already has, changes +/// nothing. +fn apply_alternate(app: &mut VectorcraftApp, id: NodeId, bytes: (usize, usize), features: &[String], has_choice: bool, pick: Pick) { + let (cmd, params) = match pick { + Pick::Glyph(gid) => ("text.setGlyphAlternate", json!({"id": id.0, "start": bytes.0, "gid": gid})), + Pick::Default | Pick::Feature(_) => { + let tag = if let Pick::Feature(tag) = &pick { Some(tag.as_str()) } else { None }; + let changes = vectorcraft_text::alternate_changes(features, tag); + if changes.is_empty() && !has_choice { + return; + } + let mut params = json!({"id": id.0, "start": bytes.0, "end": bytes.1}); + if !changes.is_empty() { + params["featureChanges"] = json!(changes); + } + if has_choice { + params["glyphAlternate"] = serde_json::Value::Null; + } + ("text.setRangeStyle", params) + } + }; + if let Err(e) = app.run(cmd, params) { + app.status(e.to_string()); + } +} + +/// The tooltip of a glyph no character maps (Show › Entire Font): its name from the font, `gid N` +/// when it has none, its id, and the feature that gives it, if one does. +fn unencoded_tip(face: &vectorcraft_text::FontFace, gid: u32) -> String { + let name = face.glyph_name(gid).unwrap_or_else(|| format!("gid {gid}")); + let glyph = crate::i18n::fmt(tl!("glyph {id}"), &[("id", &gid.to_string())]); + match face.feature_reaching(gid) { + Some(feature) => format!("{name} ({glyph}) · {}", String::from_utf8_lossy(&feature)), + None => format!("{name} ({glyph})"), + } +} + +/// The alternates of the character at `byte` of text `id` in its word, and the glyph it is drawn +/// with ([`vectorcraft_text::alternates_in_context`]). Kept in egui's temporary data until the +/// document, the character or the face changes, as [`super::character`] keeps the kerning at the +/// caret. +fn alternates_found(app: &VectorcraftApp, ui: &Ui, id: NodeId, byte: usize, face_id: u32) -> Option { + type Found = Option; let state = app.session.active(); - let key = state.map(|s| (s.uid, s.revision, id, byte, face.id())); + let key = state.map(|s| (s.uid, s.revision, id, byte, face_id)); let slot = ui.scope_id().with("gl-alternates-found"); if let Some(key) = key && let Some((cached_key, found)) = ui.ctx().data(|d| d.get_temp::<((u64, u64, NodeId, usize, u32), Found)>(slot)) @@ -433,20 +539,10 @@ fn alternates_and_drawn( { return found; } - let drawn = state - .and_then(|s| s.doc.node(id)) - .and_then(|node| match &node.kind { - NodeKind::Text(text) => Some(vectorcraft_text::layout(FontDb::global(), text)), - _ => None, - }) - .and_then(|layout| layout.glyphs.into_iter().find(|g| g.byte <= byte && byte < g.byte.saturating_add(g.len))) - .map(|g| (g.font_id, g.gid)); - let mut list = face.alternates(ch); - let default = face.glyph_for(ch); - if !list.is_empty() && default != 0 && !list.iter().any(|a| a.gid == default) { - list.insert(0, vectorcraft_text::GlyphAlternate { gid: default, feature: [0; 4] }); - } - let found = (list, drawn); + let found = state.and_then(|s| s.doc.node(id)).and_then(|node| match &node.kind { + NodeKind::Text(text) => vectorcraft_text::alternates_in_context(FontDb::global(), text, byte), + _ => None, + }); if let Some(key) = key { ui.ctx().data_mut(|d| d.insert_temp(slot, (key, found.clone()))); } @@ -800,6 +896,122 @@ mod tests { assert_eq!(highlighted(&found, &t), [squares(&found)[5].center()], "the chosen glyph, after b, u2, u4 and u3"); } + /// Double-click the glyph cell at `at`. + fn double_click(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2) { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Time only moves forward in one egui context. + let mut time = ctx.input(|i| i.time) + 10.0; + let mut timed = |app: &mut VectorcraftApp, events: Vec| { + time += 0.05; + let screen = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 420.0))); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: screen, time: Some(time), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + timed(app, vec![egui::Event::PointerMoved(at)]); + for _ in 0..2 { + timed(app, vec![button(true)]); + timed(app, vec![button(false)]); + } + } + + /// The glyph ids of the text `id`, in the order of its characters. + fn glyph_ids(app: &VectorcraftApp, id: &serde_json::Value) -> Vec { + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + let mut glyphs = vectorcraft_text::layout(FontDb::global(), text).glyphs; + glyphs.sort_by_key(|g| g.byte); + glyphs.iter().map(|g| g.gid).collect() + } + + /// For the selected medial letter of an Arabic word the grid lists that form's alternates, and + /// a double-click applies the feature that draws one to that letter alone, as one undo step: + /// the word keeps its joining forms and the text its characters; undo takes it back. + #[test] + fn a_double_click_applies_a_feature_value_to_one_letter_of_a_joined_word() { + use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font, source_sans_gid}; + FontDb::global().add_font(joining_alternates_font().expect("the test font builds")); + let g = |c| u32::from(source_sans_gid(c).expect("a glyph")); + let (mut app, id) = editing("\u{628}\u{62A}\u{646}"); + app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]); + select(&mut app, 2, 4); + let ctx = egui::Context::default(); + let t = Tokens::get(&ctx); + choose(&ctx, &mut app, "Alternates for Current Selection"); + let found = shapes(&ctx, &mut app, vec![]); + let cells = squares(&found); + assert_eq!(cells.len(), 4, "the medial teh and its three alternates, not the isolated form's"); + assert_eq!(highlighted(&found, &t), [cells[0].center()], "the form the letter has is first and drawn"); + let undo = app.session.active().unwrap().history.undo.len(); + double_click(&ctx, &mut app, cells[2].center()); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step; status: {}", app.ui.status); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), "\u{628}\u{62A}\u{646}", "the text keeps its characters"); + let features: Vec<(String, Vec)> = text.runs.iter().map(|r| (r.text.clone(), r.style.features.clone())).collect(); + assert_eq!( + features, + [("\u{628}".to_string(), vec![]), ("\u{62A}".to_string(), vec!["salt=2".to_string()]), ("\u{646}".to_string(), vec![])], + "the feature is on exactly that letter" + ); + assert!(text.runs.iter().all(|r| r.style.glyph_alternate.is_none())); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('x'), g('M')], "the neighbours keep their initial and final forms"); + // The pointer leaves the cell: a hovered cell is painted as hovered, not as the drawn one. + shapes(&ctx, &mut app, vec![egui::Event::PointerMoved(Pos2::ZERO)]); + let found = shapes(&ctx, &mut app, vec![]); + assert_eq!(highlighted(&found, &t), [squares(&found)[2].center()], "the applied variant is the one drawn"); + // The default glyph takes the feature off again, and undo takes each step back. + double_click(&ctx, &mut app, squares(&found)[0].center()); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('x'), g('M')]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert!(text.runs.iter().all(|r| r.style.features.is_empty()), "undo restores the features"); + } + + /// A variant no feature draws is still enabled: it replaces the glyph (`text.setGlyphAlternate`). + #[test] + fn a_variant_no_feature_draws_replaces_the_glyph() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let (mut app, id) = editing("xay"); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap(); + select(&mut app, 2, 2); + let ctx = egui::Context::default(); + choose(&ctx, &mut app, "Alternates for Current Selection"); + let cells = squares(&shapes(&ctx, &mut app, vec![])); + let face = FontDb::global().face(alternates_family(true), "Regular").expect("the test face"); + // The last cell is cv01's glyph c, which the style has no tag for. + double_click(&ctx, &mut app, cells[cells.len() - 1].center()); + assert_eq!(glyph_ids(&app, &id)[1], face.glyph_for('c')); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert!(text.runs.iter().any(|r| r.style.glyph_alternate.is_some())); + // A feature variant afterwards takes the choice away in the same step. + let found = shapes(&ctx, &mut app, vec![]); + let undo = app.session.active().unwrap().history.undo.len(); + double_click(&ctx, &mut app, squares(&found)[2].center()); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert!(text.runs.iter().all(|r| r.style.glyph_alternate.is_none()), "the choice is gone"); + } + + /// Show › Entire Font also lists the glyphs no character maps. + #[test] + fn entire_font_lists_the_unencoded_glyphs() { + use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font}; + FontDb::global().add_font(joining_alternates_font().expect("the test font builds")); + let (mut app, id) = editing("\u{628}"); + app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap(); + let face = FontDb::global().face(JOINING_ALTERNATES_FAMILY, "Regular").expect("the test face"); + let encoded = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count(); + let unencoded = face.unencoded_glyphs(); + assert!(unencoded.len() > 1000 && encoded == 4, "{} unencoded, {encoded} encoded", unencoded.len()); + let ctx = egui::Context::default(); + let all = texts(&ctx, &mut app); + assert!(all.iter().any(|s| s == &format!("{} glyphs", encoded + unencoded.len())), "{all:?}"); + assert!(face.glyph_name(unencoded[0]).is_some(), "the font names its glyphs"); + } + #[test] fn zoom_stops_at_the_ends() { assert_eq!(nudge_cell(CELL_DEFAULT, 1), CELL_DEFAULT + 1); diff --git a/docs/gaps.md b/docs/gaps.md index 4dac9431ee..cb915bd80a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (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-07 · **Change:** minor (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; 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 @@ -372,6 +372,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G4 Advanced type +- **Done (#5514):** the Glyphs panel's Alternates for Current Selection lists the alternates of the glyph the shaper makes for the selected character in its word, and a double-click applies the OpenType feature value that draws one (`salt=2`, `ss03`) to that character alone, in one undo step, so the text stays real Unicode and an Arabic word keeps its joining forms: runs that differ only in their features are shaped as one piece of text. A variant no feature draws there is still drawn as a replacement glyph, which may not join. Show › Entire Font lists the unencoded glyphs by id, with their name and the feature that gives them. Left: alternates reached through contextual or ligature lookups, and `cv01`–`cv99` and `hist`, which have no style tag; estimate unchanged. + - **Fixed:** fullwidth `!` / `?` stay upright in vertical point and area type when a font has no vertical alternate. Existing `vert` substitutions and the rotated fallback for brackets and long vowel marks are preserved; the broader CJK gap and estimate are unchanged. - **Done:** character-style Auto-leading resets propagate to style users while retaining local leading overrides. Sparse definitions remain sparse, native serialization retains explicit Auto, and the Character Styles override marker stays consistent. @@ -817,6 +819,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | G4: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone (`salt=2`; the style commands take `tag=N`; runs that differ only in their features shape as one piece of text), so an Arabic word stays joined, and Show › Entire Font lists the unencoded glyphs (#5514). Estimate unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-05 | minor | G15: nearby hits on plain long strokes and outlines check their candidate segments directly; a 200,000-anchor middle hit took about 11–13 ms before and 1.0–1.4 ms after in paired runs. An inside-bounds miss fell from 18.7 ms to 1.6 ms. Estimate unchanged; idle budget run still open. | | 2026-11-05 | minor | G15: an image zoomed in on is separated, colorized or proofed only for the tiles in view (#4155); 100% zoom on that file 8.6 s → 0.05 s. Estimate unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f06708..ca40e4c817 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Glyphs panel: Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone, so an Arabic word stays joined, with feature values `salt=2` in the style commands, and Entire Font lists the unencoded glyphs, #5514; 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) ## Feature submission evidence @@ -986,6 +986,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`. The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes: the grid lists the alternate glyphs of the character selected with the Type tool, and a double-click draws that character with the glyph as one undo step (`text.glyphAlternates`, `text.setGlyphAlternate`). The alternates come from the font's `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. The text keeps the character, native files keep the choice with the face it was made in, and type set in another font or version draws the default glyph. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. + The Glyphs panel's Alternates for Current Selection (#5514) applies the OpenType feature that draws the glyph to the selected character alone, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Alternates For Current Selection in the Show menu; double-click the glyph to replace the selected character). One deliberate difference from a literal reading: the text keeps the Unicode character and gains a character-style feature with a value (`salt=2`, `ss03`; `text.setRangeStyle {featureChanges}`) instead of taking an unencoded glyph in its place. The grid lists the alternates of the glyph the shaper makes for the character in its word (a medial letter shows its medial variants; the glyph it has comes first and is highlighted); a variant no feature draws there stays enabled as a replacement glyph (`text.setGlyphAlternate`), and its tooltip says it may not join its neighbors; the default glyph takes the character's alternate features off again. Runs that differ only in their OpenType features are shaped as one piece of text, each run's features over its own characters, so the letters of an Arabic word keep their joining forms. Show › Entire Font also lists the glyphs no character maps, with the font's name for them (or `gid N`) and the feature that gives them. These rules are VectorCraft's own. Tests: `tests::glyph_alternates` in the text crate (a fixture font; Kufi Murabba PIIIX when it is installed), `panels::glyphs::tests`, `tests::textedit::feature_values_are_set_replaced_and_saved`. The OpenType panel's Stylistic Sets menu offers `ss01`–`ss20` sets present in the selected text's font, using the font's custom labels when available and numbered labels otherwise. Sets can be combined and deselected individually. With mixed fonts, the menu shows the first selected character's font's sets and applies the feature tag to all selected text; a font without that set draws unchanged. The mixed-font rule is a VectorCraft design choice. Behavior reference: [Add stylistic sets to selected text](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html) and the [OpenType specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt). `text.setStyle {stroke, strokeWidth}` sets the character stroke's color and weight of the selected type or of a range of its characters (the weight in document points, as `stroke.set` sets it), in the same undo step as the command's other changes. Tests: `tests::textedit::set_style_sets_the_character_stroke`. - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. @@ -1725,6 +1726,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Type core: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to the selected letter alone, so an Arabic word stays joined (`salt=2`; the style commands take `tag=N`), and Show › Entire Font lists the glyphs no character maps (#5514; [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html)); evidence added; scores unchanged. | | 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color opens the Color Picker over the dialog, as the other effect colour fields do (#5364); correction: the #5365 entry listed Drop Shadow, whose Color is drawn outside the shared grid and had no swatch; scores unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index b36cb48921..26a1fdaa07 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -31,6 +31,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | | OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet | +| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the feature that draws the variant to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature draws there is drawn as a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. | | Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | @@ -79,6 +80,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged | From 87978f8de2e56abe82e9ffc6c164f1e46c48afa3 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:39:58 +0430 Subject: [PATCH 03/79] Text: the Kufi joining test checks the drawn ink on the installed font, never skips when it is there, and covers ss01-ss05 --- crates/text/src/tests/glyph_alternates.rs | 122 +++++++++++++++++----- 1 file changed, 94 insertions(+), 28 deletions(-) diff --git a/crates/text/src/tests/glyph_alternates.rs b/crates/text/src/tests/glyph_alternates.rs index edec6de6f0..98dcaf3b3d 100644 --- a/crates/text/src/tests/glyph_alternates.rs +++ b/crates/text/src/tests/glyph_alternates.rs @@ -133,47 +133,113 @@ fn runs_that_differ_only_in_features_shape_as_one_word() { assert_eq!(a, b, "the same places"); } -/// The Kufi Murabba PIIIX of the font's author, when it is installed: the middle letter of a word -/// takes each of its `salt` alternates and every other letter keeps the glyph it has without one. +/// Where the owner's Kufi Murabba PIIIX is installed. +const KUFI_FILE: &str = "Library/Fonts/KufiMurabbaPIIIX-Regular.ttf"; + +/// The Kufi Murabba PIIIX of the font's author in a font database of its own, `None` when it is not +/// installed (the test then has nothing to read). When the file is there, a face that does not +/// load is a failure, never a skip. +fn kufi() -> Option<(FontDb, std::sync::Arc)> { + let path = std::path::Path::new(&std::env::var_os("HOME")?).join(KUFI_FILE); + if !path.exists() { + eprintln!("skipped: {} is not installed", path.display()); + return None; + } + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{} exists but cannot be read: {e}", path.display())); + let db = FontDb::with_font_dirs(vec![]); + let added = db.add_font(bytes); + assert!(added > 0, "{} exists but no face of it loaded", path.display()); + let face = db.face("Kufi Murabba PIIIX", "Regular").expect("a face"); + assert_eq!((face.family.as_str(), face.style.as_str()), ("Kufi Murabba PIIIX", "Regular"), "the installed font's face, not a fallback"); + Some((db, face)) +} + +/// The word al-Khwarizmi: alef, lam, khah, waw, alef, reh, zain, meem, farsi yeh. The lam, khah and +/// waw join (initial, medial, final); the letters around them do not join forward. +const KHWARIZMI: &str = "الخوارزمی"; +/// The medial khah, the middle letter of the joined lam-khah-waw. +const KHAH: usize = 2; + +/// The word with `features` on the character `at` only, shaped as one piece (the runs differ only +/// in features), in reading order of the characters. +fn khwarizmi(db: &FontDb, family: &str, at: usize, features: &[&str]) -> Vec { + let letters: Vec = KHWARIZMI.chars().collect(); + let part = |r: std::ops::Range| -> String { letters[r].iter().collect() }; + let (before, middle, after) = (part(0..at), part(at..at + 1), part(at + 1..letters.len())); + set(db, family, &[(&before, &[]), (&middle, features), (&after, &[])]) +} + +/// The gap between the drawn outlines of the letters that join (the lam, khah and waw), along the +/// line: 0 when the ink of one reaches the ink of the next. Unlike [`gaps`], which only checks that +/// the pen advances by each glyph's advance, this reads the glyphs drawn. +fn ink_gaps(glyphs: &[PositionedGlyph]) -> Vec { + use kurbo::Shape; + let mut spans: Vec<(f64, f64)> = glyphs[KHAH - 1..=KHAH + 1].iter().map(|g| (g.outline.bounding_box().x0, g.outline.bounding_box().x1)).collect(); + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + spans.windows(2).map(|w| w[1].0 - w[0].1).collect() +} + +/// One line of what the shaper made, for a failure message and `--nocapture`. +fn describe(glyphs: &[PositionedGlyph]) -> String { + glyphs.iter().map(|g| format!("{}@{:.1}+{:.1}", g.gid, g.origin.x, g.advance)).collect::>().join(" ") +} + +/// The Kufi Murabba PIIIX of the font's author, when it is installed: the medial khah of a word +/// takes each of its `salt` alternates, every other letter keeps the glyph it has without one and +/// the letters still touch. #[test] fn kufi_murabba_alternates_keep_the_word_joined() { - let Some(path) = - std::env::var_os("HOME").map(|h| std::path::Path::new(&h).join("Library/Fonts/KufiMurabbaPIIIX-Regular.ttf")).filter(|p| p.exists()) - else { - return; - }; - let Ok(bytes) = std::fs::read(path) else { return }; - let db = FontDb::with_font_dirs(vec![]); - db.add_font(bytes); - let Some(face) = db.face("Kufi Murabba PIIIX", "Regular") else { return }; + let Some((db, face)) = kufi() else { return }; let family = face.family.clone(); - let letters: Vec = "الخوارزمی".chars().collect(); - let text = |pieces: &[(String, Vec<&str>)]| { - let pieces: Vec<(&str, &[&str])> = pieces.iter().map(|(t, f)| (t.as_str(), f.as_slice())).collect(); - set(&db, &family, &pieces) - }; - let plain = text(&[(letters.iter().collect(), vec![])]); - assert_eq!(plain.len(), letters.len(), "one glyph for each letter"); + let plain = khwarizmi(&db, &family, KHAH, &[]); + assert_eq!(plain.len(), KHWARIZMI.chars().count(), "one glyph for each letter: {}", describe(&plain)); assert!(gaps(&plain).iter().all(|gap| gap.abs() < 1e-3), "the word joins without a gap: {:?}", gaps(&plain)); - // The medial khah. - let at = 2; - let default = plain[at].gid; + assert!(ink_gaps(&plain).iter().all(|gap| gap.abs() < 1e-3), "the drawn letters touch: {:?}", ink_gaps(&plain)); + let default = plain[KHAH].gid; + assert_eq!(face.glyph_name(default).as_deref(), Some("khah.medi"), "the khah in the middle of the word takes its medial form"); let salt = face.substitutions(default).into_iter().find(|s| &s.feature == b"salt" && s.glyphs.len() >= 2).expect("the medial khah has salt alternates"); for (i, alternate) in salt.glyphs.iter().enumerate() { let value = format!("salt={}", i + 1); - let before: String = letters[..at].iter().collect(); - let after: String = letters[at + 1..].iter().collect(); - let glyphs = text(&[(before, vec![]), (letters[at].to_string(), vec![value.as_str()]), (after, vec![])]); - assert_eq!(glyphs.len(), plain.len(), "{value}"); - assert_eq!(glyphs[at].gid, *alternate, "{value}: the alternate"); - for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != at) { - assert_eq!(a.gid, b.gid, "{value}: the letter {n} keeps its form"); + let glyphs = khwarizmi(&db, &family, KHAH, &[value.as_str()]); + assert_eq!(glyphs.len(), plain.len(), "{value}: {}", describe(&glyphs)); + assert_eq!(glyphs[KHAH].gid, *alternate, "{value}: the alternate"); + assert_ne!(glyphs[KHAH].gid, default, "{value}: not the default glyph"); + assert_eq!(face.glyph_name(*alternate), Some(format!("khah.medi.alt{}", i + 1)), "{value}: an alternate of the medial form"); + for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != KHAH) { + assert_eq!(a.gid, b.gid, "{value}: the letter {n} keeps its form: {}", describe(&glyphs)); } assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{value}: no gap: {:?}", gaps(&glyphs)); + assert!(ink_gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{value}: the drawn letters touch: {:?}", ink_gaps(&glyphs)); } } +/// The stylistic sets `ss01` to `ss05` on a letter in the middle of a joined group: the glyph the +/// font's set gives its medial form (the default when it has none), the same forms for the others, +/// no gap. +#[test] +fn kufi_murabba_stylistic_sets_on_a_middle_letter_keep_the_word_joined() { + let Some((db, face)) = kufi() else { return }; + let family = face.family.clone(); + let plain = khwarizmi(&db, &family, KHAH, &[]); + let default = plain[KHAH].gid; + let mut changed = 0; + for set_number in 1..=5 { + let tag = format!("ss{set_number:02}"); + let wanted = face.substitutions(default).into_iter().find(|s| s.feature == tag.as_bytes() && !s.glyphs.is_empty()).map(|s| s.glyphs[0]); + let glyphs = khwarizmi(&db, &family, KHAH, &[tag.as_str()]); + assert_eq!(glyphs.len(), plain.len(), "{tag}: {}", describe(&glyphs)); + assert_eq!(glyphs[KHAH].gid, wanted.unwrap_or(default), "{tag}: the set's glyph for the medial khah"); + changed += usize::from(wanted.is_some_and(|w| w != default)); + for (n, (a, b)) in glyphs.iter().zip(&plain).enumerate().filter(|(n, _)| *n != KHAH) { + assert_eq!(a.gid, b.gid, "{tag}: the letter {n} keeps its form: {}", describe(&glyphs)); + } + assert!(gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{tag}: no gap: {:?}", gaps(&glyphs)); + assert!(ink_gaps(&glyphs).iter().all(|gap| gap.abs() < 1e-3), "{tag}: the drawn letters touch: {:?}", ink_gaps(&glyphs)); + } + assert!(changed >= 1, "the font has stylistic sets for the medial khah"); +} + /// The `GSUB` reader gives the alternate set of each glyph (position in the set = the feature's /// value), single substitutions, the feature that reaches a glyph, names and the glyph count. #[test] From 2b1d657d8a9500d6a2c389c69b9de2d10e90c136 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:55:00 +0430 Subject: [PATCH 04/79] Glyphs: Entire Font applies an unencoded glyph that is the selected letter's alternate, dims the others with the reason, and inserts a presentation form's letter so the word joins --- crates/text/src/fontdb.rs | 2 +- crates/text/src/fontdb/gsub.rs | 73 +++- crates/text/src/lib.rs | 6 +- crates/text/src/tests/glyph_alternates.rs | 57 ++- crates/ui-egui/src/i18n/bg.tsv | 12 +- crates/ui-egui/src/i18n/bs.tsv | 12 +- crates/ui-egui/src/i18n/ca.tsv | 12 +- crates/ui-egui/src/i18n/cs.tsv | 12 +- crates/ui-egui/src/i18n/da.tsv | 12 +- crates/ui-egui/src/i18n/de.tsv | 12 +- crates/ui-egui/src/i18n/el.tsv | 12 +- crates/ui-egui/src/i18n/es.tsv | 12 +- crates/ui-egui/src/i18n/fi.tsv | 12 +- crates/ui-egui/src/i18n/fr.tsv | 12 +- crates/ui-egui/src/i18n/hi.tsv | 12 +- crates/ui-egui/src/i18n/hu.tsv | 12 +- crates/ui-egui/src/i18n/id.tsv | 12 +- crates/ui-egui/src/i18n/it.tsv | 12 +- crates/ui-egui/src/i18n/ja.tsv | 12 +- crates/ui-egui/src/i18n/ka.tsv | 12 +- crates/ui-egui/src/i18n/ko.tsv | 12 +- crates/ui-egui/src/i18n/nl.tsv | 12 +- crates/ui-egui/src/i18n/pl.tsv | 12 +- crates/ui-egui/src/i18n/pt-br.tsv | 12 +- crates/ui-egui/src/i18n/ru.tsv | 12 +- crates/ui-egui/src/i18n/sv.tsv | 12 +- crates/ui-egui/src/i18n/tr.tsv | 12 +- crates/ui-egui/src/i18n/uk.tsv | 12 +- crates/ui-egui/src/i18n/vi.tsv | 12 +- crates/ui-egui/src/i18n/zh-hans.tsv | 12 +- crates/ui-egui/src/i18n/zh-hant.tsv | 12 +- crates/ui-egui/src/panels/glyphs.rs | 433 +++++++++++++++++++++- docs/gaps.md | 2 +- docs/type-parity.md | 4 +- 34 files changed, 844 insertions(+), 57 deletions(-) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index c7d116a29d..44be66ae12 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -51,7 +51,7 @@ use face::Instances; pub(crate) use face::MAX_LIVE_INSTANCES; pub use face::{FALLBACK_FAMILY, FontFace, IcfMargins}; pub(crate) use glyph_pen::FlipPen; -pub use gsub::{Substitution, SubstitutionKind}; +pub use gsub::{GlyphOrigin, JoiningPosition, Substitution, SubstitutionKind}; #[cfg(not(target_arch = "wasm32"))] use names::SysFallback; #[cfg(test)] diff --git a/crates/text/src/fontdb/gsub.rs b/crates/text/src/fontdb/gsub.rs index 3b07aad668..888c89ab09 100644 --- a/crates/text/src/fontdb/gsub.rs +++ b/crates/text/src/fontdb/gsub.rs @@ -83,6 +83,8 @@ impl Substitution { } } +type Tag = [u8; 4]; + /// A face's substitutions, by the glyph they replace and by the glyph they give. #[derive(Default)] pub(super) struct GsubIndex { @@ -90,8 +92,8 @@ pub(super) struct GsubIndex { /// lookup. by_input: HashMap>, /// The first feature, listed ones first, that gives each glyph: by a single, multiple, - /// alternate or ligature substitution. - reached: HashMap, + /// alternate or ligature substitution, and the glyph it replaces (not known for a ligature). + reached: HashMap)>, } /// The glyph single substitution `t` puts in place of each glyph its coverage holds. @@ -209,7 +211,7 @@ impl GsubIndex { let out: Vec = out.into_iter().filter(|g| *g < glyphs).collect(); for g in &out { if *g != input && index.reached.len() < MAX_ENTRIES { - index.reached.entry(*g).or_insert(tag); + index.reached.entry(*g).or_insert((tag, Some(input))); } } if listed && !out.is_empty() && entries < MAX_ENTRIES { @@ -240,7 +242,7 @@ impl GsubIndex { if covered.insert(input) { for g in to.into_iter().filter(|g| *g < glyphs && *g != input) { if index.reached.len() < MAX_ENTRIES { - index.reached.entry(g).or_insert(tag); + index.reached.entry(g).or_insert((tag, Some(input))); } } } @@ -251,7 +253,7 @@ impl GsubIndex { for t in s.iter().take(n).inspect(|_| reads -= 1).flatten() { for g in ligature_glyphs(&t, MAX_ENTRIES).into_iter().filter(|g| *g < glyphs) { if index.reached.len() < MAX_ENTRIES { - index.reached.entry(g).or_insert(tag); + index.reached.entry(g).or_insert((tag, None)); } } } @@ -263,6 +265,40 @@ impl GsubIndex { } } +/// The most substitutions [`FontFace::glyph_origin`] follows back. +const MAX_ORIGIN_STEPS: usize = 8; + +/// The character a glyph comes from ([`FontFace::glyph_origin`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GlyphOrigin { + pub ch: char, + /// The features from the glyph back to the character's glyph, the glyph's own first. + pub features: Vec<[u8; 4]>, +} + +impl GlyphOrigin { + /// The place in a word that the glyph's features give it: the first of `init`, `medi`, `fina` + /// and `isol` among them, `None` when none is (a letter that does not join). + pub fn position(&self) -> Option { + self.features.iter().find_map(|f| match f { + b"init" => Some(crate::JoiningPosition::Initial), + b"medi" => Some(crate::JoiningPosition::Medial), + b"fina" => Some(crate::JoiningPosition::Final), + b"isol" => Some(crate::JoiningPosition::Isolated), + _ => None, + }) + } +} + +/// Where a letter stands in a word, as the `init`, `medi`, `fina` and `isol` features say. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JoiningPosition { + Initial, + Medial, + Final, + Isolated, +} + impl FontFace { fn gsub_index(&self) -> &GsubIndex { self.gsub.get_or_init(|| GsubIndex::read(self)) @@ -285,7 +321,32 @@ impl FontFace { /// multiple, alternate or ligature substitution), a listed feature ([`Self::substitutions`]) /// before another (`init`, `liga`), if any does. Not read through a contextual lookup. pub fn feature_reaching(&self, gid: u32) -> Option<[u8; 4]> { - self.gsub_index().reached.get(&gid).copied() + self.gsub_index().reached.get(&gid).map(|r| r.0) + } + + /// Where glyph `gid` comes from: the character whose glyph the `GSUB` table's substitutions + /// lead to it from, and the features on the way, `gid`'s own first (`aalt`, then `fina` for + /// `yehfarsi.fina.alt2`, whose character is U+06CC). The chain follows the first substitution + /// that gives each glyph back to a glyph none gives, as far as [`MAX_ORIGIN_STEPS`] steps; a + /// presentation-form character that maps a glyph in the chain does not stop it, and the + /// character is the lowest one outside the Arabic presentation forms that maps the glyph it + /// ends on (the lowest of all when only presentation forms do). `None` when no character maps + /// that glyph (a glyph no feature reaches, a ligature). + pub fn glyph_origin(&self, gid: u32) -> Option { + let index = self.gsub_index(); + let mut features = vec![]; + let mut at = gid; + for _ in 0..MAX_ORIGIN_STEPS { + let Some((tag, from)) = index.reached.get(&at) else { break }; + features.push(*tag); + at = (*from)?; + } + let font = self.skrifa()?; + let presentation = |c: u32| (0xFB50..=0xFDFF).contains(&c) || (0xFE70..=0xFEFF).contains(&c); + let mut chars: Vec = font.charmap().mappings().filter(|(_, g)| g.to_u32() == at).map(|(c, _)| c).collect(); + chars.sort_by_key(|c| (presentation(*c), *c)); + let ch = chars.first().copied().and_then(char::from_u32)?; + Some(GlyphOrigin { ch, features }) } /// The name of glyph `gid` from the font's `post` or `CFF` table: `None` for a glyph past the diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index b593594fbf..095e26f0de 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -39,9 +39,9 @@ pub use features::{ EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_FEATURE_VALUE, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by, }; pub use fontdb::{ - FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, PlatformFontFiles, StylisticSet, Substitution, - SubstitutionKind, WantedFont, WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, - set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, user_font_dirs, + FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, GlyphOrigin, IcfMargins, JoiningPosition, PlatformFontFiles, + StylisticSet, Substitution, SubstitutionKind, WantedFont, WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, + set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined, style_weight, system_font_dirs, user_font_dirs, }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; use std::sync::Arc; diff --git a/crates/text/src/tests/glyph_alternates.rs b/crates/text/src/tests/glyph_alternates.rs index 98dcaf3b3d..992dc9f711 100644 --- a/crates/text/src/tests/glyph_alternates.rs +++ b/crates/text/src/tests/glyph_alternates.rs @@ -136,11 +136,19 @@ fn runs_that_differ_only_in_features_shape_as_one_word() { /// Where the owner's Kufi Murabba PIIIX is installed. const KUFI_FILE: &str = "Library/Fonts/KufiMurabbaPIIIX-Regular.ttf"; +/// The owner's Heavy weight of it, the one a final yeh was reported against. +const KUFI_HEAVY_FILE: &str = "Library/Fonts/KufiMurabbaPIIIX-Heavy.ttf"; + /// The Kufi Murabba PIIIX of the font's author in a font database of its own, `None` when it is not /// installed (the test then has nothing to read). When the file is there, a face that does not /// load is a failure, never a skip. fn kufi() -> Option<(FontDb, std::sync::Arc)> { - let path = std::path::Path::new(&std::env::var_os("HOME")?).join(KUFI_FILE); + kufi_weight(KUFI_FILE, "Regular") +} + +/// [`kufi`] for the installed file `file` (under the home folder), whose face has style `style`. +fn kufi_weight(file: &str, style: &str) -> Option<(FontDb, std::sync::Arc)> { + let path = std::path::Path::new(&std::env::var_os("HOME")?).join(file); if !path.exists() { eprintln!("skipped: {} is not installed", path.display()); return None; @@ -149,8 +157,8 @@ fn kufi() -> Option<(FontDb, std::sync::Arc)> { let db = FontDb::with_font_dirs(vec![]); let added = db.add_font(bytes); assert!(added > 0, "{} exists but no face of it loaded", path.display()); - let face = db.face("Kufi Murabba PIIIX", "Regular").expect("a face"); - assert_eq!((face.family.as_str(), face.style.as_str()), ("Kufi Murabba PIIIX", "Regular"), "the installed font's face, not a fallback"); + let face = db.face("Kufi Murabba PIIIX", style).expect("a face"); + assert_eq!((face.family.as_str(), face.style.as_str()), ("Kufi Murabba PIIIX", style), "the installed font's face, not a fallback"); Some((db, face)) } @@ -357,3 +365,46 @@ fn a_feature_on_a_one_byte_character_reaches_it() { let gids: Vec = layout(&db, &text).glyphs.iter().map(|g| g.gid).collect(); assert_eq!(gids, [face.glyph_for('x'), face.glyph_for('b'), face.glyph_for('y')], "salt draws b for the a alone"); } + +/// The owner's Kufi Murabba PIIIX Heavy, with the final letter of al-Khwarizmi as Persian yeh +/// (U+06CC), Arabic yeh (U+064A) or alef maksura (U+0649): in each the letter takes its own final +/// form, the byte offset of the last character finds its alternates, `salt` to `salt=6` draw them +/// on that letter alone with the rest of the word and its joining untouched, and the glyph an +/// alternate is (`yehfarsi.fina.alt2`) is traced back to its letter and its place in the word. +#[test] +fn kufi_murabba_heavy_final_letters_alternates_belong_to_their_letter() { + let Some((db, face)) = kufi_weight(KUFI_HEAVY_FILE, "Heavy") else { return }; + let family = face.family.clone(); + for (letter, base) in [('\u{06CC}', "yehfarsi"), ('\u{064A}', "yeh"), ('\u{0649}', "alefmaksura")] { + let head: String = KHWARIZMI.chars().take(KHWARIZMI.chars().count() - 1).collect(); + let word = format!("{head}{letter}"); + let last = word.len() - letter.len_utf8(); + let letter = letter.to_string(); + let plain = set(&db, &family, &[(&head, &[]), (&letter, &[])]); + assert_eq!(plain.len(), KHWARIZMI.chars().count(), "{base}: one glyph for each letter: {}", describe(&plain)); + assert_eq!(face.glyph_name(plain[plain.len() - 1].gid), Some(format!("{base}.fina")), "{base}: the letter ends the word in its final form"); + let mut text = TextObject::point(Point::ZERO, "", style(&family, &[])); + text.runs = vec![TextRun { text: word.clone(), style: style(&family, &[]), inline: None }]; + let found = alternates_in_context(&db, &text, last).expect("the final letter"); + assert_eq!((found.default, found.current), (plain[plain.len() - 1].gid, plain[plain.len() - 1].gid), "{base}"); + assert_eq!(found.alternates.len(), 6, "{base}: the six alternates of the final form"); + for (n, a) in found.alternates.iter().enumerate() { + let tag = if n == 0 { "salt".to_string() } else { format!("salt={}", n + 1) }; + assert_eq!(a.apply.as_deref(), Some(tag.as_str()), "{base}: the feature that draws alternate {}", n + 1); + assert_eq!(face.glyph_name(a.gid), Some(format!("{base}.fina.alt{}", n + 1)), "{base}"); + let glyphs = set(&db, &family, &[(&head, &[]), (&letter, &[tag.as_str()])]); + assert_eq!(glyphs.len(), plain.len(), "{base} {tag}: {}", describe(&glyphs)); + assert_eq!(glyphs[glyphs.len() - 1].gid, a.gid, "{base} {tag}: the alternate"); + assert!( + glyphs.iter().zip(&plain).take(plain.len() - 1).all(|(x, y)| x.gid == y.gid), + "{base} {tag}: the rest of the word keeps its forms" + ); + let origin = face.glyph_origin(a.gid).expect("the alternate comes from a letter"); + assert_eq!(origin.ch.to_string(), letter, "{base}: {} is a form of its own letter", face.glyph_name(a.gid).unwrap_or_default()); + assert_eq!(origin.position(), Some(JoiningPosition::Final), "{base}: and of its end of a word"); + } + } + // Glyph 820 of the owner's file is yehfarsi.fina.alt2: Persian yeh's, at the end of a word. + let g820 = face.glyph_origin(820).expect("glyph 820 comes from a letter"); + assert_eq!((face.glyph_name(820).as_deref(), g820.ch, g820.position()), (Some("yehfarsi.fina.alt2"), '\u{06CC}', Some(JoiningPosition::Final))); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index dc53b5b246..3539c19742 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -846,8 +846,18 @@ font-technology Variable Променливи The selected character's font is not available. Шрифтът на избрания знак не е наличен. No alternates for the selected character. Няма алтернативи за избрания знак. Double-click to replace Двойно щракване за замяна + Select one character with the Type tool first. Първо изберете един знак с инструмента „Текст“. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} е глиф на {font}, а не на шрифта, с който е зададен избраният текст ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} принадлежи на {base} (U+{base_code}) {place}; избраният от вас знак е {sel} (U+{code}). + at the start of a word в началото на дума + in the middle of a word в средата на дума + at the end of a word в края на дума + standing alone когато стои сам + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не е алтернатива на {sel} (U+{code}) там, където е в думата си. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Никоя OpenType функция не извежда {glyph}, затова не може да се приложи към текст. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Двойно щракване за вмъкване на {base} (U+{code}): представителната форма не се свързва, буквата се свързва. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Вмъкнато е {base} (U+{code}) вместо представителна форма: шрифтът я свързва със съседите според позицията. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Нито една OpenType функция не рисува този глиф тук, затова той заменя глифа на знака и може да не се свързва със съседните знаци. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Този глиф няма знак, затова не може да се вмъкне. Използвайте „Алтернативи за текущата селекция“, за да го приложите. Double-click to insert Двойно щракване за вмъкване Double-click to rename Двойно щракване за преименуване Double-click to target the contents Двойно щракване за насочване към съдържанието diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a4c34938b5..dd785fc5d5 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1554,8 +1554,18 @@ digits Malayalam Malajalam The selected character's font is not available. Font odabranog znaka nije dostupan. No alternates for the selected character. Nema alternativa za odabrani znak. Double-click to replace Dvostruki klik za zamjenu + Select one character with the Type tool first. Prvo odaberite jedan znak alatom za tekst. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} je glif fonta {font}, a ne fonta kojim je postavljen odabrani tekst ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pripada znaku {base} (U+{base_code}) {place}; vaš odabir je {sel} (U+{code}). + at the start of a word na početku riječi + in the middle of a word usred riječi + at the end of a word na kraju riječi + standing alone kad stoji sam + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} nije alternativa znaku {sel} (U+{code}) onako kako stoji u svojoj riječi. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Nijedna OpenType funkcija ne daje {glyph}, pa se ne može primijeniti na tekst. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dvostruki klik za umetanje znaka {base} (U+{code}): oblik za prikaz se ne spaja, slovo se spaja. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Umetnut je znak {base} (U+{code}) umjesto oblika za prikaz: font ga spaja sa susjedima prema položaju. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nijedna OpenType funkcija ne crta ovaj glif ovdje, pa on zamjenjuje glif znaka i možda se neće spajati sa susjednim znakovima. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ovaj glif nema znak, pa se ne može umetnuti. Koristite „Alternative za trenutni odabir“ da ga primijenite. Double-click to insert Dvostruki klik za umetanje Entire Font Cijeli font glyph {id} glif {id} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 7af14f8aab..cc106dbafa 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1208,8 +1208,18 @@ The selected character's font is not available. El tipus de lletra del caràcter seleccionat no està disponible. No alternates for the selected character. No hi ha alternatives per al caràcter seleccionat. Double-click to replace Feu doble clic per substituir + Select one character with the Type tool first. Selecciona primer un caràcter amb l’eina de text. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} és un glif de {font}, no del tipus de lletra amb què està escrit el text seleccionat ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertany a {base} (U+{base_code}) {place}; la teva selecció és {sel} (U+{code}). + at the start of a word a l’inici d’una paraula + in the middle of a word al mig d’una paraula + at the end of a word al final d’una paraula + standing alone quan va sol + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} no és una alternativa de {sel} (U+{code}) tal com es troba a la seva paraula. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Cap funció OpenType arriba a {glyph}, de manera que no es pot aplicar al text. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Feu doble clic per inserir {base} (U+{code}): una forma de presentació no s’uneix, la lletra sí. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. S’ha inserit {base} (U+{code}) en lloc d’una forma de presentació: el tipus de lletra l’uneix als veïns segons la posició. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Cap funció OpenType dibuixa aquest glif aquí, de manera que substitueix el glif del caràcter i pot no unir-se als caràcters veïns. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Aquest glif no té cap caràcter, de manera que no es pot inserir. Feu servir «Alternatives per a la selecció actual» per aplicar-lo. Double-click to insert Feu doble clic per inserir Double-click to rename Feu doble clic per canviar el nom Double-click to target the contents Feu doble clic per apuntar al contingut diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 93c816d1c6..c7be3ff0dd 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1578,8 +1578,18 @@ digits Malayalam Malajálamské The selected character's font is not available. Písmo vybraného znaku není k dispozici. No alternates for the selected character. Vybraný znak nemá žádné alternativy. Double-click to replace Dvojklikem nahradíte + Select one character with the Type tool first. Nejprve nástrojem Text vyberte jeden znak. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} je glyf písma {font}, ne písma, kterým je vybraný text nastaven ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} patří znaku {base} (U+{base_code}) {place}; váš výběr je {sel} (U+{code}). + at the start of a word na začátku slova + in the middle of a word uprostřed slova + at the end of a word na konci slova + standing alone když stojí samostatně + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} není alternativou znaku {sel} (U+{code}) tak, jak stojí ve svém slově. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Žádná funkce OpenType nedává {glyph}, proto ho nelze použít na text. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dvojklikem vložíte {base} (U+{code}): prezentační tvar se nespojuje, písmeno ano. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Vloženo {base} (U+{code}) místo prezentačního tvaru: písmo ho podle polohy spojí se sousedy. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Žádná funkce OpenType tento glyf zde nekreslí, proto nahradí glyf znaku a nemusí se spojit se sousedními znaky. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Tento glyf nemá žádný znak, takže ho nelze vložit. Použijte „Alternativy pro aktuální výběr“. Double-click to insert Dvojklikem vložíte Entire Font Celé písmo glyph {id} glyf {id} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 4917bd8168..681444c936 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1488,8 +1488,18 @@ digits Malayalam Malayalam The selected character's font is not available. Skrifttypen for det markerede tegn er ikke tilgængelig. No alternates for the selected character. Ingen alternativer til det markerede tegn. Double-click to replace Dobbeltklik for at erstatte + Select one character with the Type tool first. Markér først ét tegn med tekstværktøjet. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} er et glyf i {font}, ikke i den skrifttype, den markerede tekst er sat i ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hører til {base} (U+{base_code}) {place}; din markering er {sel} (U+{code}). + at the start of a word i begyndelsen af et ord + in the middle of a word midt i et ord + at the end of a word i slutningen af et ord + standing alone når det står alene + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} er ikke et alternativ til {sel} (U+{code}), som det står i sit ord. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Ingen OpenType-funktion giver {glyph}, så det kan ikke anvendes på tekst. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dobbeltklik for at indsætte {base} (U+{code}) i stedet: en præsentationsform forbindes ikke, bogstavet gør. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) er indsat i stedet for en præsentationsform: skrifttypen forbinder det med naboerne efter position. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion tegner dette glyf her, så det erstatter tegnets glyf og forbindes måske ikke med nabotegnene. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Dette glyf har intet tegn og kan derfor ikke indsættes. Brug „Alternativer til den aktuelle markering“ for at anvende det. Double-click to insert Dobbeltklik for at indsætte Entire Font Hele skrifttypen glyph {id} glyf {id} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 678162e111..10b78c831d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -840,8 +840,18 @@ font-technology Variable Variabel The selected character's font is not available. Die Schrift des ausgewählten Zeichens ist nicht verfügbar. No alternates for the selected character. Keine Alternativen für das ausgewählte Zeichen. Double-click to replace Doppelklicken zum Ersetzen + Select one character with the Type tool first. Wählen Sie zuerst mit dem Text-Werkzeug ein Zeichen aus. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} ist eine Glyphe von {font}, nicht der Schrift, in der der ausgewählte Text gesetzt ist ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} gehört zu {base} (U+{base_code}) {place}; Ihre Auswahl ist {sel} (U+{code}). + at the start of a word am Wortanfang + in the middle of a word in der Wortmitte + at the end of a word am Wortende + standing alone allein stehend + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} ist keine Alternative zu {sel} (U+{code}), so wie es in seinem Wort steht. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Keine OpenType-Funktion liefert {glyph}, daher lässt es sich nicht auf Text anwenden. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Doppelklicken, um stattdessen {base} (U+{code}) einzufügen: Eine Darstellungsform verbindet sich nicht, der Buchstabe schon. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) statt einer Darstellungsform eingefügt: Die Schrift verbindet es je nach Position mit den Nachbarn. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Keine OpenType-Funktion zeichnet diese Glyphe hier, daher ersetzt sie die Glyphe des Zeichens und verbindet sich möglicherweise nicht mit den Nachbarzeichen. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Diese Glyphe hat kein Zeichen und lässt sich daher nicht einfügen. Verwenden Sie „Alternativen für aktuelle Auswahl“, um sie anzuwenden. Double-click to insert Doppelklicken zum Einfügen Double-click to rename Doppelklicken zum Umbenennen Double-click to target the contents Doppelklicken, um den Inhalt als Ziel auszuwählen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3e5d56de29..a222d9274f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -819,8 +819,18 @@ font-technology Variable Μεταβλητές The selected character's font is not available. Η γραμματοσειρά του επιλεγμένου χαρακτήρα δεν είναι διαθέσιμη. No alternates for the selected character. Δεν υπάρχουν εναλλακτικοί για τον επιλεγμένο χαρακτήρα. Double-click to replace Διπλό κλικ για αντικατάσταση + Select one character with the Type tool first. Επιλέξτε πρώτα έναν χαρακτήρα με το εργαλείο κειμένου. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). Ο γλύφος {glyph} ανήκει στη γραμματοσειρά {font}, όχι σε αυτήν του επιλεγμένου κειμένου ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). Ο γλύφος {glyph} ανήκει στο {base} (U+{base_code}) {place}· η επιλογή σας είναι {sel} (U+{code}). + at the start of a word στην αρχή μιας λέξης + in the middle of a word στη μέση μιας λέξης + at the end of a word στο τέλος μιας λέξης + standing alone όταν στέκεται μόνος + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. Ο γλύφος {glyph} δεν είναι εναλλακτικός του {sel} (U+{code}) όπως βρίσκεται στη λέξη του. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Καμία λειτουργία OpenType δεν δίνει τον γλύφο {glyph}, οπότε δεν μπορεί να εφαρμοστεί στο κείμενο. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Διπλό κλικ για εισαγωγή του {base} (U+{code}): μια μορφή παρουσίασης δεν συνδέεται, το γράμμα συνδέεται. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Εισήχθη το {base} (U+{code}) αντί για μορφή παρουσίασης: η γραμματοσειρά το συνδέει με τους γείτονες ανάλογα με τη θέση. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Καμία λειτουργία OpenType δεν σχεδιάζει αυτόν τον γλύφο εδώ, οπότε αντικαθιστά τον γλύφο του χαρακτήρα και μπορεί να μην συνδέεται με τους γειτονικούς χαρακτήρες. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Αυτός ο γλύφος δεν έχει χαρακτήρα και δεν μπορεί να εισαχθεί. Χρησιμοποιήστε τους «Εναλλακτικούς για την τρέχουσα επιλογή» για να τον εφαρμόσετε. Double-click to insert Διπλό κλικ για εισαγωγή Double-click to rename Διπλό κλικ για μετονομασία Double-click to target the contents Διπλό κλικ για επιλογή του περιεχομένου ως στόχου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5a262f20d5..8d93dfbbe5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -861,8 +861,18 @@ font-technology Variable Variables The selected character's font is not available. La fuente del carácter seleccionado no está disponible. No alternates for the selected character. No hay alternativas para el carácter seleccionado. Double-click to replace Doble clic para reemplazar + Select one character with the Type tool first. Primero selecciona un carácter con la herramienta Texto. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} es un glifo de {font}, no de la fuente del texto seleccionado ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertenece a {base} (U+{base_code}) {place}; tu selección es {sel} (U+{code}). + at the start of a word al principio de una palabra + in the middle of a word en medio de una palabra + at the end of a word al final de una palabra + standing alone cuando está solo + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} no es una alternativa de {sel} (U+{code}) tal como está en su palabra. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Ninguna función OpenType llega a {glyph}, así que no se puede aplicar al texto. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Doble clic para insertar {base} (U+{code}) en su lugar: una forma de presentación no se une, la letra sí. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Se insertó {base} (U+{code}) en lugar de una forma de presentación: la fuente la une a sus vecinas según la posición. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ninguna función OpenType dibuja este glifo aquí, así que sustituye el glifo del carácter y puede que no se una a los caracteres vecinos. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Este glifo no tiene carácter, así que no se puede insertar. Usa «Alternativas para la selección actual» para aplicarlo. Double-click to insert Doble clic para insertar Double-click to rename Doble clic para cambiar el nombre Double-click to target the contents Doble clic para seleccionar el contenido como destino diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 202e008e12..0673e39ce8 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1551,8 +1551,18 @@ digits Malayalam Malajalam The selected character's font is not available. Valitun merkin fontti ei ole saatavilla. No alternates for the selected character. Valitulla merkillä ei ole vaihtoehtoja. Double-click to replace Korvaa kaksoisnapsauttamalla + Select one character with the Type tool first. Valitse ensin yksi merkki tekstityökalulla. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} on fontin {font} glyfi, ei valitun tekstin fontin ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} kuuluu merkille {base} (U+{base_code}) {place}; valintasi on {sel} (U+{code}). + at the start of a word sanan alussa + in the middle of a word sanan keskellä + at the end of a word sanan lopussa + standing alone yksinään + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} ei ole merkin {sel} (U+{code}) vaihtoehto sellaisena kuin se on sanassaan. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Mikään OpenType-ominaisuus ei tuota glyfiä {glyph}, joten sitä ei voi käyttää tekstiin. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Lisää kaksoisnapsauttamalla sen sijaan {base} (U+{code}): esitysmuoto ei liity, kirjain liittyy. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Lisättiin {base} (U+{code}) esitysmuodon sijaan: fontti liittää sen naapureihin sijainnin mukaan. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Mikään OpenType-ominaisuus ei piirrä tätä glyfiä tässä, joten se korvaa merkin glyfin eikä välttämättä liity viereisiin merkkeihin. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Tällä glyfillä ei ole merkkiä, joten sitä ei voi lisätä. Käytä toimintoa ”Nykyisen valinnan vaihtoehdot” sen käyttöön. Double-click to insert Lisää kaksoisnapsauttamalla Entire Font Koko fontti glyph {id} merkki {id} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f93b915232..fb5619bb2a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -864,8 +864,18 @@ font-technology Variable Variables The selected character's font is not available. La police du caractère sélectionné n’est pas disponible. No alternates for the selected character. Aucune variante pour le caractère sélectionné. Double-click to replace Double-cliquez pour remplacer + Select one character with the Type tool first. Sélectionnez d’abord un caractère avec l’outil Texte. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} est un glyphe de {font}, et non de la police du texte sélectionné ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} appartient à {base} (U+{base_code}) {place} ; votre sélection est {sel} (U+{code}). + at the start of a word au début d’un mot + in the middle of a word au milieu d’un mot + at the end of a word à la fin d’un mot + standing alone isolé + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} n’est pas une variante de {sel} (U+{code}) tel qu’il se trouve dans son mot. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Aucune fonction OpenType n’atteint {glyph} ; il ne peut donc pas être appliqué au texte. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Double-cliquez pour insérer {base} (U+{code}) à la place : une forme de présentation ne se lie pas, la lettre si. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) a été inséré à la place d’une forme de présentation : la police le lie à ses voisins selon la position. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Aucune fonction OpenType ne dessine ce glyphe ici : il remplace le glyphe du caractère et peut ne pas se lier aux caractères voisins. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ce glyphe n’a pas de caractère et ne peut donc pas être inséré. Utilisez « Variantes de la sélection » pour l’appliquer. Double-click to insert Double-cliquez pour insérer Double-click to rename Double-cliquez pour renommer Double-click to target the contents Double-cliquez pour cibler le contenu diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 5b8b11cc50..5274287e32 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1344,8 +1344,18 @@ Double-click to insert सम्मिलित करने के लिए डबल क्लिक करें Double-click to rename नाम बदलने के लिए डबल क्लिक करें Double-click to replace प्रतिस्थापित करने के लिए डबल क्लिक करें + Select one character with the Type tool first. पहले टाइप टूल से एक कैरेक्टर सिलेक्ट करें। + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} {font} का ग्लिफ़ है, उस फ़ॉन्ट का नहीं जिसमें सिलेक्ट किया टेक्स्ट सेट है ({text_font})। + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} {base} (U+{base_code}) का है, {place}; आपका सिलेक्शन {sel} (U+{code}) है। + at the start of a word शब्द की शुरुआत में + in the middle of a word शब्द के बीच में + at the end of a word शब्द के अंत में + standing alone अकेले खड़े होने पर + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}, {sel} (U+{code}) का विकल्प नहीं है, जैसा वह अपने शब्द में खड़ा है। + No OpenType feature reaches {glyph}, so it cannot be applied to text. कोई OpenType फ़ीचर {glyph} तक नहीं पहुँचता, इसलिए इसे टेक्स्ट पर लागू नहीं किया जा सकता। + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. इसके बजाय {base} (U+{code}) सम्मिलित करने के लिए डबल क्लिक करें: प्रेज़ेंटेशन फ़ॉर्म नहीं जुड़ता, अक्षर जुड़ता है। + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. प्रेज़ेंटेशन फ़ॉर्म की जगह {base} (U+{code}) सम्मिलित किया गया: फ़ॉन्ट उसे स्थिति के अनुसार पड़ोसियों से जोड़ता है। No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. यहाँ कोई OpenType फ़ीचर इस ग्लिफ़ को नहीं बनाता, इसलिए यह कैरेक्टर के ग्लिफ़ की जगह लेता है और पड़ोसी कैरेक्टरों से जुड़ नहीं भी सकता। - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. इस ग्लिफ़ का कोई कैरेक्टर नहीं है, इसलिए इसे सम्मिलित नहीं किया जा सकता। इसे लागू करने के लिए “वर्तमान सिलेक्शन के लिए ऑप्शन” का उपयोग करें। Double-click to target the contents सामग्री को लक्षित करने के लिए डबल क्लिक करें Down (right reading) नीचे (दाएं पढ़ने) Download डाउनलोड diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 516ccf02de..b89d479ef6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1114,8 +1114,18 @@ font-technology Variable Változó The selected character's font is not available. A kijelölt karakter betűtípusa nem érhető el. No alternates for the selected character. A kijelölt karakternek nincsenek változatai. Double-click to replace Csere dupla kattintással + Select one character with the Type tool first. Előbb jelölj ki egy karaktert a Szöveg eszközzel. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). A(z) {glyph} a(z) {font} glifája, nem a kijelölt szöveg betűtípusáé ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). A(z) {glyph} ehhez tartozik: {base} (U+{base_code}) {place}; a te kijelölésed: {sel} (U+{code}). + at the start of a word szó elején + in the middle of a word szó közepén + at the end of a word szó végén + standing alone önállóan állva + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. A(z) {glyph} nem változata a(z) {sel} (U+{code}) karakternek úgy, ahogy a szavában áll. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Egyetlen OpenType-funkció sem éri el a(z) {glyph} glifát, ezért nem alkalmazható a szövegre. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dupla kattintással ezt szúrod be helyette: {base} (U+{code}); a megjelenítési forma nem kapcsolódik, a betű igen. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. A megjelenítési forma helyett ez lett beszúrva: {base} (U+{code}); a betűtípus pozíció szerint kapcsolja a szomszédokhoz. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Itt egyetlen OpenType-funkció sem rajzolja meg ezt a glifát, ezért a karakter glifáját váltja fel, és előfordulhat, hogy nem kapcsolódik a szomszédos karakterekhez. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ennek a glifának nincs karaktere, ezért nem szúrható be. Az alkalmazásához használd az „Az aktuális kijelölés változatai” nézetet. Double-click to insert Beszúrás dupla kattintással Double-click to rename Dupla kattintással átnevezhető Double-click to target the contents Dupla kattintással a tartalom lesz a cél diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 6b488a850e..64d77d1617 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1488,8 +1488,18 @@ digits Malayalam Malayalam The selected character's font is not available. Font karakter yang dipilih tidak tersedia. No alternates for the selected character. Tidak ada alternatif untuk karakter yang dipilih. Double-click to replace Klik ganda untuk mengganti + Select one character with the Type tool first. Pilih satu karakter dengan alat teks terlebih dahulu. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} adalah glif {font}, bukan dari font yang dipakai teks terpilih ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} milik {base} (U+{base_code}) {place}; pilihan Anda adalah {sel} (U+{code}). + at the start of a word di awal kata + in the middle of a word di tengah kata + at the end of a word di akhir kata + standing alone saat berdiri sendiri + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} bukan alternatif {sel} (U+{code}) sebagaimana posisinya di dalam katanya. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Tidak ada fitur OpenType yang menghasilkan {glyph}, jadi tidak bisa diterapkan ke teks. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Klik ganda untuk menyisipkan {base} (U+{code}) sebagai gantinya: bentuk presentasi tidak menyambung, hurufnya menyambung. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) disisipkan sebagai ganti bentuk presentasi: font menyambungkannya ke huruf di sebelahnya menurut posisi. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Tidak ada fitur OpenType yang menggambar glif ini di sini, jadi glif ini menggantikan glif karakter dan mungkin tidak menyambung dengan karakter di sebelahnya. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Glif ini tidak punya karakter, sehingga tidak bisa disisipkan. Gunakan “Alternatif untuk Pilihan Saat Ini” untuk menerapkannya. Double-click to insert Klik ganda untuk menyisipkan Entire Font Seluruh font glyph {id} glif {id} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 433847dbf1..8b9d0331f7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -861,8 +861,18 @@ font-technology Variable Variabili The selected character's font is not available. Il font del carattere selezionato non è disponibile. No alternates for the selected character. Nessuna alternativa per il carattere selezionato. Double-click to replace Fai doppio clic per sostituire + Select one character with the Type tool first. Seleziona prima un carattere con lo strumento Testo. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} è un glifo di {font}, non del font del testo selezionato ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} appartiene a {base} (U+{base_code}) {place}; la tua selezione è {sel} (U+{code}). + at the start of a word all’inizio di una parola + in the middle of a word in mezzo a una parola + at the end of a word alla fine di una parola + standing alone da solo + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} non è un’alternativa di {sel} (U+{code}) così come si trova nella sua parola. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Nessuna funzione OpenType raggiunge {glyph}, quindi non può essere applicato al testo. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Fai doppio clic per inserire invece {base} (U+{code}): una forma di presentazione non si unisce, la lettera sì. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Inserito {base} (U+{code}) al posto di una forma di presentazione: il font lo unisce ai vicini in base alla posizione. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nessuna funzione OpenType disegna questo glifo qui: sostituisce il glifo del carattere e potrebbe non unirsi ai caratteri vicini. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Questo glifo non ha un carattere e non può essere inserito. Usa «Alternative per la selezione corrente» per applicarlo. Double-click to insert Fai doppio clic per inserire Double-click to rename Fai doppio clic per rinominare Double-click to target the contents Fai doppio clic per impostare il contenuto come destinazione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e0d8f5c2e8..5a3de37329 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1424,8 +1424,18 @@ digits Malayalam マラヤーラム数字 The selected character's font is not available. 選択した文字のフォントを使用できません。 No alternates for the selected character. 選択した文字には異体字がありません。 Double-click to replace ダブルクリックで置換 + Select one character with the Type tool first. 先に文字ツールで文字を 1 つ選択してください。 + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} は {font} のグリフで、選択したテキストのフォント ({text_font}) のものではありません。 + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} は {base} (U+{base_code}) の{place}のグリフです。選択中の文字は {sel} (U+{code}) です。 + at the start of a word 語頭 + in the middle of a word 語中 + at the end of a word 語末 + standing alone 単独のとき + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} は、{sel} (U+{code}) が単語の中で置かれている位置での異体字ではありません。 + No OpenType feature reaches {glyph}, so it cannot be applied to text. {glyph} に到達する OpenType フィーチャーはないため、テキストに適用できません。 + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. ダブルクリックで代わりに {base} (U+{code}) を挿入: 表示形は前後とつながりませんが、文字はつながります。 + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 表示形の代わりに {base} (U+{code}) を挿入しました。フォントが位置に応じて前後の文字とつなげます。 No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. このグリフを表示する OpenType フィーチャーがないため、文字のグリフが置き換わり、前後の文字とつながらないことがあります。 - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. このグリフには対応する文字がないため、挿入できません。適用するには「選択文字の異体字」を使用してください。 Double-click to insert ダブルクリックで挿入 Double-click to rename ダブルクリックで名前を変更 Double-click to target the contents ダブルクリックで内容をターゲット diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 9e473e2f3d..9d48ca867a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1414,8 +1414,18 @@ digits Malayalam მალაიალამური The selected character's font is not available. მონიშნული სიმბოლოს შრიფტი მიუწვდომელია. No alternates for the selected character. მონიშნულ სიმბოლოს ალტერნატივები არ აქვს. Double-click to replace ორჯერ დაწკაპეთ ჩასანაცვლებლად + Select one character with the Type tool first. ჯერ ტექსტის ხელსაწყოთი მონიშნეთ ერთი სიმბოლო. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} არის {font}-ის გლიფი და არა იმ შრიფტისა, რომლითაც მონიშნული ტექსტია დაწყობილი ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} ეკუთვნის სიმბოლოს {base} (U+{base_code}) {place}; თქვენი მონიშნულია {sel} (U+{code}). + at the start of a word სიტყვის დასაწყისში + in the middle of a word სიტყვის შუაში + at the end of a word სიტყვის ბოლოს + standing alone როცა ცალკე დგას + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} არ არის {sel} (U+{code})-ის ალტერნატივა იმ სახით, როგორც ის თავის სიტყვაშია. + No OpenType feature reaches {glyph}, so it cannot be applied to text. არც ერთი OpenType ფუნქცია არ იძლევა {glyph}-ს, ამიტომ მისი ტექსტზე გამოყენება შეუძლებელია. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. ორჯერ დაწკაპეთ ამის ნაცვლად {base} (U+{code}) ჩასასმელად: გამოსახვის ფორმა არ უკავშირდება, ასო უკავშირდება. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. გამოსახვის ფორმის ნაცვლად ჩასმულია {base} (U+{code}): შრიფტი მას მეზობლებს პოზიციის მიხედვით აკავშირებს. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. აქ არც ერთი OpenType ფუნქცია არ ხატავს ამ გლიფს, ამიტომ ის ანაცვლებს სიმბოლოს გლიფს და შეიძლება არ დაუკავშირდეს მეზობელ სიმბოლოებს. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. ამ გლიფს სიმბოლო არ აქვს, ამიტომ მისი ჩასმა შეუძლებელია. გამოსაყენებლად გამოიყენეთ „მიმდინარე მონიშვნის ალტერნატივები“. Double-click to insert ორჯერ დაწკაპეთ ჩასასმელად Entire Font მთელი შრიფტი glyph {id} გლიფი {id} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8333201b82..e23a535e28 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1414,8 +1414,18 @@ digits Malayalam 말라얄람어 The selected character's font is not available. 선택한 문자의 글꼴을 사용할 수 없습니다. No alternates for the selected character. 선택한 문자의 대체 글리프가 없습니다. Double-click to replace 더블클릭하여 바꾸기 + Select one character with the Type tool first. 먼저 문자 도구로 문자 하나를 선택하세요. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph}은(는) {font}의 글리프이며, 선택한 텍스트의 글꼴({text_font})의 것이 아닙니다. + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph}은(는) {base}(U+{base_code})의 {place} 글리프입니다. 선택한 문자는 {sel}(U+{code})입니다. + at the start of a word 단어 처음 + in the middle of a word 단어 중간 + at the end of a word 단어 끝 + standing alone 단독일 때 + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}은(는) 단어 안에 있는 {sel}(U+{code})의 대체 글리프가 아닙니다. + No OpenType feature reaches {glyph}, so it cannot be applied to text. {glyph}에 도달하는 OpenType 기능이 없으므로 텍스트에 적용할 수 없습니다. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 더블클릭하여 대신 {base}(U+{code}) 삽입: 표시 형태는 연결되지 않지만 글자는 연결됩니다. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 표시 형태 대신 {base}(U+{code})을(를) 삽입했습니다. 글꼴이 위치에 따라 이웃 문자와 연결합니다. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 이 글리프를 그리는 OpenType 기능이 없으므로 문자의 글리프를 대체하며 이웃 문자와 연결되지 않을 수 있습니다. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. 이 글리프에는 문자가 없어 삽입할 수 없습니다. 적용하려면 ‘현재 선택 항목의 대체 글리프’를 사용하세요. Double-click to insert 더블클릭하여 삽입 Double-click to rename 더블클릭하여 이름 변경 Double-click to target the contents 더블클릭하여 내용 선택 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 8fb204e2d6..c352a8184b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1502,8 +1502,18 @@ digits Malayalam Malayalam The selected character's font is not available. Het lettertype van het geselecteerde teken is niet beschikbaar. No alternates for the selected character. Geen alternatieven voor het geselecteerde teken. Double-click to replace Dubbelklik om te vervangen + Select one character with the Type tool first. Selecteer eerst één teken met het Type-gereedschap. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} is een glyph van {font}, niet van het lettertype van de geselecteerde tekst ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hoort bij {base} (U+{base_code}) {place}; uw selectie is {sel} (U+{code}). + at the start of a word aan het begin van een woord + in the middle of a word midden in een woord + at the end of a word aan het einde van een woord + standing alone los staand + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} is geen alternatief voor {sel} (U+{code}) zoals het in zijn woord staat. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Geen enkele OpenType-functie levert {glyph}, dus het kan niet op tekst worden toegepast. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dubbelklik om in plaats daarvan {base} (U+{code}) in te voegen: een presentatievorm sluit niet aan, de letter wel. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) ingevoegd in plaats van een presentatievorm: het lettertype verbindt het naar positie met de buren. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Geen enkele OpenType-functie tekent deze glyph hier, dus hij vervangt de glyph van het teken en sluit mogelijk niet aan op de buurtekens. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Deze glyph heeft geen teken en kan dus niet worden ingevoegd. Gebruik ‘Alternatieven voor huidige selectie’ om hem toe te passen. Double-click to insert Dubbelklik om in te voegen Entire Font Volledig lettertype glyph {id} glyph {id} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 86af456b81..93e96b79d6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1491,8 +1491,18 @@ digits Malayalam Malajalam The selected character's font is not available. Czcionka zaznaczonego znaku jest niedostępna. No alternates for the selected character. Brak alternatyw dla zaznaczonego znaku. Double-click to replace Kliknij dwukrotnie, aby zastąpić + Select one character with the Type tool first. Najpierw zaznacz jeden znak narzędziem Tekst. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} to glif czcionki {font}, a nie czcionki zaznaczonego tekstu ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} należy do znaku {base} (U+{base_code}) {place}; Twoje zaznaczenie to {sel} (U+{code}). + at the start of a word na początku wyrazu + in the middle of a word w środku wyrazu + at the end of a word na końcu wyrazu + standing alone gdy stoi osobno + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} nie jest alternatywą znaku {sel} (U+{code}) w takiej postaci, w jakiej stoi w swoim wyrazie. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Żadna funkcja OpenType nie daje glifu {glyph}, więc nie można go zastosować do tekstu. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Kliknij dwukrotnie, aby wstawić zamiast tego {base} (U+{code}): forma prezentacyjna się nie łączy, litera tak. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Wstawiono {base} (U+{code}) zamiast formy prezentacyjnej: czcionka łączy go z sąsiadami zależnie od pozycji. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Żadna funkcja OpenType nie rysuje tutaj tego glifu, więc zastępuje on glif znaku i może nie łączyć się z sąsiednimi znakami. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Ten glif nie ma znaku, więc nie można go wstawić. Użyj opcji „Alternatywy bieżącego zaznaczenia”, aby go zastosować. Double-click to insert Kliknij dwukrotnie, aby wstawić Entire Font Cała czcionka glyph {id} glif {id} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5e5a4396cf..05b84eb732 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -771,8 +771,18 @@ The selected character's font is not available. A fonte do caractere selecionado não está disponível. No alternates for the selected character. Nenhuma alternativa para o caractere selecionado. Double-click to replace Clique duplo para substituir + Select one character with the Type tool first. Primeiro selecione um caractere com a ferramenta Texto. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} é um glifo de {font}, não da fonte do texto selecionado ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertence a {base} (U+{base_code}) {place}; sua seleção é {sel} (U+{code}). + at the start of a word no início de uma palavra + in the middle of a word no meio de uma palavra + at the end of a word no fim de uma palavra + standing alone quando está sozinho + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} não é uma alternativa de {sel} (U+{code}) como ele está em sua palavra. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Nenhum recurso OpenType chega a {glyph}, então ele não pode ser aplicado ao texto. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Clique duplo para inserir {base} (U+{code}) no lugar: uma forma de apresentação não se une, a letra sim. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) foi inserido no lugar de uma forma de apresentação: a fonte o une aos vizinhos conforme a posição. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nenhum recurso OpenType desenha este glifo aqui, então ele substitui o glifo do caractere e pode não se unir aos caracteres vizinhos. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Este glifo não tem caractere e não pode ser inserido. Use “Alternativas para a seleção atual” para aplicá-lo. Double-click to insert Clique duplo para inserir Double-click to rename Clique duplo para renomear Double-click to target the contents Clique duplo para direcionar o conteúdo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6de9bb6713..f7bab2610e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -859,8 +859,18 @@ font-technology Variable Вариативные The selected character's font is not available. Шрифт выделенного символа недоступен. No alternates for the selected character. У выделенного символа нет альтернатив. Double-click to replace Дважды щёлкните для замены + Select one character with the Type tool first. Сначала выделите один символ инструментом «Текст». + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} — глиф шрифта {font}, а не шрифта выделенного текста ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} относится к символу {base} (U+{base_code}) {place}; выделен символ {sel} (U+{code}). + at the start of a word в начале слова + in the middle of a word в середине слова + at the end of a word в конце слова + standing alone в отдельном виде + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не является альтернативой символа {sel} (U+{code}) в том виде, как он стоит в своём слове. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Ни одна функция OpenType не даёт {glyph}, поэтому его нельзя применить к тексту. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Дважды щёлкните, чтобы вставить вместо него {base} (U+{code}): форма представления не соединяется, буква соединяется. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Вместо формы представления вставлено {base} (U+{code}): шрифт соединяет его с соседними по положению. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ни одна функция OpenType не рисует здесь этот глиф, поэтому он заменяет глиф символа и может не соединяться с соседними символами. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. У этого глифа нет символа, поэтому его нельзя вставить. Чтобы применить его, используйте «Альтернативы для текущего выделения». Double-click to insert Дважды щёлкните для вставки Double-click to rename Дважды щёлкните для переименования Double-click to target the contents Дважды щёлкните, чтобы выбрать содержимое как объект diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 87735c99d9..e7a42b0a03 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -848,8 +848,18 @@ font-technology Variable Variabla The selected character's font is not available. Typsnittet för det markerade tecknet är inte tillgängligt. No alternates for the selected character. Inga alternativ för det markerade tecknet. Double-click to replace Dubbelklicka för att ersätta + Select one character with the Type tool first. Markera först ett tecken med textverktyget. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} är en glyf i {font}, inte i det typsnitt den markerade texten är satt i ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hör till {base} (U+{base_code}) {place}; din markering är {sel} (U+{code}). + at the start of a word i början av ett ord + in the middle of a word mitt i ett ord + at the end of a word i slutet av ett ord + standing alone när det står ensamt + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} är inget alternativ till {sel} (U+{code}) som det står i sitt ord. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Ingen OpenType-funktion ger {glyph}, så den kan inte användas på text. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dubbelklicka för att infoga {base} (U+{code}) i stället: en presentationsform ansluter inte, bokstaven gör det. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) infogades i stället för en presentationsform: typsnittet ansluter det till grannarna efter position. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion ritar den här glyfen här, så den ersätter tecknets glyf och ansluter kanske inte till grannteckenen. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Den här glyfen har inget tecken och kan därför inte infogas. Använd ”Alternativ för aktuell markering” för att tillämpa den. Double-click to insert Dubbelklicka för att infoga Double-click to rename Dubbelklicka för att byta namn Double-click to target the contents Dubbelklicka för att välja innehållet som mål diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 13c68896dd..4d286f83d8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -840,8 +840,18 @@ font-technology Variable Değişken The selected character's font is not available. Seçili karakterin yazı tipi kullanılamıyor. No alternates for the selected character. Seçili karakter için alternatif yok. Double-click to replace Değiştirmek için çift tıklayın + Select one character with the Type tool first. Önce Yazım aracıyla bir karakter seçin. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph}, seçili metnin yazı tipine ({text_font}) değil, {font} yazı tipine ait bir glif. + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph}, {base} (U+{base_code}) karakterine aittir ({place}); seçiminiz {sel} (U+{code}). + at the start of a word kelimenin başında + in the middle of a word kelimenin ortasında + at the end of a word kelimenin sonunda + standing alone tek başına dururken + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}, kendi kelimesinde durduğu hâliyle {sel} (U+{code}) karakterinin alternatifi değil. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Hiçbir OpenType özelliği {glyph} glifine ulaşmaz; bu yüzden metne uygulanamaz. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Bunun yerine {base} (U+{code}) eklemek için çift tıklayın: sunum biçimi bağlanmaz, harf bağlanır. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Sunum biçimi yerine {base} (U+{code}) eklendi: yazı tipi onu konumuna göre komşularına bağlar. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Hiçbir OpenType özelliği bu glifi burada çizmez; bu yüzden karakterin glifinin yerine geçer ve komşu karakterlere bağlanmayabilir. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Bu glifin karakteri yok, bu yüzden eklenemez. Uygulamak için “Geçerli Seçim için Alternatifler”i kullanın. Double-click to insert Eklemek için çift tıklayın Double-click to rename Yeniden adlandırmak için çift tıklayın Double-click to target the contents İçeriği hedeflemek için çift tıklayın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6a8756e775..094b517f0b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -855,8 +855,18 @@ font-technology Variable Варіативні The selected character's font is not available. Шрифт виділеного символу недоступний. No alternates for the selected character. Виділений символ не має альтернатив. Double-click to replace Двічі клацніть для заміни + Select one character with the Type tool first. Спершу виділіть один символ інструментом «Текст». + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} — гліф шрифту {font}, а не шрифту виділеного тексту ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} належить символу {base} (U+{base_code}) {place}; виділено символ {sel} (U+{code}). + at the start of a word на початку слова + in the middle of a word у середині слова + at the end of a word наприкінці слова + standing alone в окремому вигляді + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не є альтернативою символу {sel} (U+{code}) у тому вигляді, в якому він стоїть у своєму слові. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Жодна функція OpenType не дає {glyph}, тому його не можна застосувати до тексту. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Двічі клацніть, щоб вставити замість нього {base} (U+{code}): форма подання не з’єднується, літера з’єднується. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Замість форми подання вставлено {base} (U+{code}): шрифт з’єднує його із сусідніми за положенням. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Жодна функція OpenType не малює тут цей гліф, тому він замінює гліф символу й може не з’єднуватися із сусідніми символами. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. У цього гліфа немає символу, тому його не можна вставити. Щоб застосувати його, скористайтеся «Альтернативи для поточного виділення». Double-click to insert Двічі клацніть для вставки Double-click to rename Двічі клацніть для перейменування Double-click to target the contents Двічі клацніть, щоб вибрати вміст як об’єкт diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1e1061a79b..d06d1368f8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -999,8 +999,18 @@ font-technology Variable Biến thiên The selected character's font is not available. Phông chữ của ký tự đã chọn không khả dụng. No alternates for the selected character. Ký tự đã chọn không có glyph thay thế. Double-click to replace Bấm đúp để thay thế + Select one character with the Type tool first. Trước hết hãy chọn một ký tự bằng công cụ Chữ. + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} là glyph của {font}, không phải của phông chữ mà văn bản đã chọn đang dùng ({text_font}). + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} thuộc về {base} (U+{base_code}) {place}; ký tự bạn chọn là {sel} (U+{code}). + at the start of a word ở đầu từ + in the middle of a word ở giữa từ + at the end of a word ở cuối từ + standing alone khi đứng riêng + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} không phải glyph thay thế của {sel} (U+{code}) như ký tự đó đang đứng trong từ của nó. + No OpenType feature reaches {glyph}, so it cannot be applied to text. Không có tính năng OpenType nào đạt tới {glyph}, nên không thể áp dụng cho văn bản. + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Bấm đúp để chèn {base} (U+{code}) thay thế: dạng trình bày không nối, còn chữ cái thì nối. + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Đã chèn {base} (U+{code}) thay cho dạng trình bày: phông chữ nối nó với các ký tự bên cạnh theo vị trí. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Không có tính năng OpenType nào vẽ glyph này ở đây, nên nó thay glyph của ký tự và có thể không nối với các ký tự bên cạnh. - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. Glyph này không có ký tự nên không thể chèn. Hãy dùng “Glyph thay thế cho vùng chọn hiện tại” để áp dụng. Double-click to insert Bấm đúp để chèn Double-click to rename Bấm đúp để đổi tên Double-click to target the contents Bấm đúp để chọn nội dung diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ae82c23144..dffc014d0d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -803,8 +803,18 @@ The selected character's font is not available. 所选字符的字体不可用。 No alternates for the selected character. 所选字符没有替代字。 Double-click to replace 双击以替换 + Select one character with the Type tool first. 请先用文字工具选择一个字符。 + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} 是 {font} 的字形,不是所选文本所用字体({text_font})的字形。 + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} 属于 {base}(U+{base_code}){place};您选择的是 {sel}(U+{code})。 + at the start of a word 位于词首 + in the middle of a word 位于词中 + at the end of a word 位于词尾 + standing alone 单独出现时 + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} 不是 {sel}(U+{code})在其单词中所处位置的替代字。 + No OpenType feature reaches {glyph}, so it cannot be applied to text. 没有任何 OpenType 功能能得到 {glyph},因此无法应用于文本。 + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 双击以改为插入 {base}(U+{code}):表现形式不会连接,字母会连接。 + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 已插入 {base}(U+{code})以代替表现形式:字体会按位置将其与相邻字符连接。 No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 没有任何 OpenType 功能会在此处绘制该字形,因此它会替换该字符的字形,并且可能无法与相邻字符连接。 - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. 此字形没有对应的字符,无法插入。请使用“当前所选字符的替代字”来应用它。 Double-click to insert 双击以插入 Double-click to rename 双击以重命名 Double-click to target the contents 双击以指定内容 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 841cff8558..d581b8963d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -823,8 +823,18 @@ font-technology Variable 可變 The selected character's font is not available. 選取字元的字體無法使用。 No alternates for the selected character. 選取的字元沒有替代字。 Double-click to replace 按兩下以取代 + Select one character with the Type tool first. 請先用文字工具選取一個字元。 + {glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} 是 {font} 的字形,不是所選文字所用字體({text_font})的字形。 + {glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} 屬於 {base}(U+{base_code}){place};您選取的是 {sel}(U+{code})。 + at the start of a word 位於詞首 + in the middle of a word 位於詞中 + at the end of a word 位於詞尾 + standing alone 單獨出現時 + {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} 不是 {sel}(U+{code})在其單字中所處位置的替代字。 + No OpenType feature reaches {glyph}, so it cannot be applied to text. 沒有任何 OpenType 功能能得到 {glyph},因此無法套用到文字。 + Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 按兩下以改為插入 {base}(U+{code}):表現形式不會連接,字母會連接。 + Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 已插入 {base}(U+{code})以取代表現形式:字體會依位置將其與相鄰字元連接。 No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 沒有任何 OpenType 功能會在此處繪製該字形,因此它會取代該字元的字形,且可能無法與相鄰字元連接。 - This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it. 此字形沒有對應的字元,無法插入。請使用「目前選取字元的替代字」來套用。 Double-click to insert 按兩下以插入 Double-click to rename 按兩下以重新命名 Double-click to target the contents 按兩下以指定內容 diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index b4d0ab6904..80163af8bc 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -4,7 +4,10 @@ //! The character selected with the Type tool is highlighted in the grid. //! Show › Alternates for Current Selection lists that character's alternate glyphs instead (as it //! stands in its word), and a double-click applies the OpenType feature that draws one to that -//! character alone, so the text stays real text that joins its neighbors. +//! character alone, so the text stays real text that joins its neighbors. A double-click on a +//! glyph no character maps in Entire Font does the same when the selected character's font gives +//! that glyph as one of its alternates in its word; any other glyph is drawn dimmed, and its tooltip +//! says why it cannot be applied to the selection. use std::collections::HashMap; @@ -253,6 +256,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let list_h = (ui.available_height() - 64.0).max(cell * 3.0); let hover_id = egui::Id::unique("gl-hover"); let selected = selected_char(app); + // What a double-click on an unencoded glyph can do to the selected character. + let fit = (!unencoded.is_empty()).then(|| Fit::of(app, ui, &face)); // The selected character and the rows shown last frame: the grid scrolls to the selected glyph // only when the selection moves to another character, so it still scrolls freely meanwhile. let follow_id = egui::Id::unique("gl-follow"); @@ -264,7 +269,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { && !shown.contains(&(i / cols)) { let row_h = cell + ui.spacing().item_spacing.y; - area = area.vertical_scroll_offset(((i / cols) as f32 + 0.5) * row_h - list_h * 0.5); + area = area.vertical_scroll_offset((((i / cols) as f32 + 0.5) * row_h - list_h * 0.5).max(0.0)); } let shown = area.show_rows(ui, cell, rows, |ui, range| { for r in range.clone() { @@ -285,19 +290,42 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } let edge = if chosen { Stroke::new(1.5, t.accent) } else { Stroke::new(0.5, t.border) }; ui.painter().rect_stroke(rect.shrink(0.5), 0, edge, StrokeKind::Inside); - let tex = glyph_texture(ui.ctx(), &face, gid, px); - ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong); - let tip = match c { - Some(c) => format!("U+{:04X} {c} (glyph {gid})\n{}", c as u32, tl!("Double-click to insert")), - None => unencoded_tip(&face, gid), + // A glyph no character maps is dimmed when it cannot go on the selection. + let pick = match (c, &fit) { + (None, Some(fit)) => Some(fit.pick(gid)), + _ => None, }; - let resp = resp.on_hover_text(tip); + let ink = if matches!(pick, Some(None)) { t.text_disabled } else { t.text_strong }; + let tex = glyph_texture(ui.ctx(), &face, gid, px); + ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), ink); + // The tooltip is made when it is shown: working out why a glyph cannot be + // applied reads the font, which hundreds of cells cannot do each frame. + let resp = resp.on_hover_ui(|ui| { + let tip = match (c, &pick) { + (Some(c), _) => encoded_tip(&face, c, gid), + (None, Some(Some(_))) => format!("{}\n{}", unencoded_tip(&face, gid), tl!("Double-click to replace")), + (None, Some(None)) => { + let why = fit.as_ref().map(|f| f.why(&face, gid)).unwrap_or_default(); + format!("{}\n{why}", unencoded_tip(&face, gid)) + } + (None, None) => unencoded_tip(&face, gid), + }; + ui.label(tip); + }); if resp.double_clicked() { - match c { - Some(c) => insert(app, ui.ctx(), c), - None => app.status(tl!( - "This glyph has no character, so it can't be inserted. Use Alternates for Current Selection to apply it." - )), + match (c, pick) { + (Some(c), _) => insert_character(app, ui.ctx(), &face, c, gid), + (None, Some(Some(pick))) => { + if let Some(fit) = &fit { + fit.apply(app, pick); + } + } + (None, Some(None)) => { + if let Some(fit) = &fit { + app.status(fit.why(&face, gid)); + } + } + (None, None) => {} } } } @@ -513,6 +541,167 @@ fn apply_alternate(app: &mut VectorcraftApp, id: NodeId, bytes: (usize, usize), } } +/// The Type tool's selected character, and what its word offers, for a double-click on a glyph no +/// character maps in Show › Entire Font ([`Fit::pick`]). +struct Fit { + /// The selected character: its text, where it is and how it is styled. `None` without one. + target: Option, + /// The id of the face the panel browses; its glyph ids are what is applied. + face_id: u32, +} + +struct FitTarget { + id: NodeId, + byte: usize, + ch: char, + features: Vec, + has_choice: bool, + /// The family the selected text is set in, and whether the app has it. + family: String, + available: bool, + /// The alternates of the character in its word, from the face of its text. + found: Option, +} + +impl Fit { + fn of(app: &VectorcraftApp, ui: &Ui, face: &vectorcraft_text::FontFace) -> Self { + let db = FontDb::global(); + let target = selected_target(app).map(|t| { + let text_face = db.style_face(t.style).filter(|_| db.has_family(&t.style.font_family)); + let found = text_face.as_ref().and_then(|f| alternates_found(app, ui, t.id, t.byte, f.id())); + FitTarget { + id: t.id, + byte: t.byte, + ch: t.ch, + features: t.style.features.clone(), + has_choice: t.style.glyph_alternate.is_some(), + family: t.style.font_family.clone(), + available: text_face.is_some(), + found, + } + }); + Fit { target, face_id: face.id() } + } + + /// What a double-click on glyph `gid` does to the selected character: the way to draw it with + /// that glyph, when it is the character's default glyph or one of its alternates as it stands + /// in its word, in the face the selected text is set in. `None` when it is not. + fn pick(&self, gid: u32) -> Option { + let t = self.target.as_ref().filter(|t| t.available)?; + let found = t.found.as_ref().filter(|f| f.font_id == self.face_id)?; + if gid == found.default { + return Some(Pick::Default); + } + let a = found.alternates.iter().find(|a| a.gid == gid)?; + Some(a.apply.clone().map_or(Pick::Glyph(gid), Pick::Feature)) + } + + /// Why [`Self::pick`] finds nothing for glyph `gid` of `face`, in the user's words: what the + /// glyph belongs to (its letter and place in a word, from the font's own substitutions) and what + /// the selection is, that no feature reaches it, or that nothing is selected. + fn why(&self, face: &vectorcraft_text::FontFace, gid: u32) -> String { + let Some(t) = &self.target else { return tl!("Select one character with the Type tool first.").to_string() }; + if !t.available { + return tl!("The selected character's font is not available.").to_string(); + } + let name = glyph_label(face, gid); + if let Some(found) = t.found.as_ref().filter(|f| f.font_id != self.face_id) { + let other = FontDb::global().face_by_id(found.font_id).map_or_else(|| t.family.clone(), |f| format!("{} {}", f.family, f.style)); + return crate::i18n::fmt( + tl!("{glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font})."), + &[("glyph", &name), ("font", &format!("{} {}", face.family, face.style)), ("text_font", &other)], + ); + } + let (sel, sel_code) = (t.ch.to_string(), format!("{:04X}", t.ch as u32)); + let not_alternate = || { + crate::i18n::fmt( + tl!("{glyph} is not an alternate of {sel} (U+{code}) as it stands in its word."), + &[("glyph", &name), ("sel", &sel), ("code", &sel_code)], + ) + }; + let Some(origin) = face.glyph_origin(gid) else { + return match face.feature_reaching(gid) { + Some(_) => not_alternate(), + None => crate::i18n::fmt(tl!("No OpenType feature reaches {glyph}, so it cannot be applied to text."), &[("glyph", &name)]), + }; + }; + let place = match origin.position() { + Some(vectorcraft_text::JoiningPosition::Initial) => Some(tl!("at the start of a word")), + Some(vectorcraft_text::JoiningPosition::Medial) => Some(tl!("in the middle of a word")), + Some(vectorcraft_text::JoiningPosition::Final) => Some(tl!("at the end of a word")), + Some(vectorcraft_text::JoiningPosition::Isolated) => Some(tl!("standing alone")), + None => None, + }; + let (Some(place), true) = (place, origin.ch != t.ch) else { return not_alternate() }; + crate::i18n::fmt( + tl!("{glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code})."), + &[ + ("glyph", &name), + ("base", &origin.ch.to_string()), + ("base_code", &format!("{:04X}", origin.ch as u32)), + ("place", place), + ("sel", &sel), + ("code", &sel_code), + ], + ) + } + + /// Apply `pick` to the selected character, as a double-click on a variant of Show › Alternates + /// for Current Selection does. + fn apply(&self, app: &mut VectorcraftApp, pick: Pick) { + let Some(t) = &self.target else { return }; + super::character::end_typing(app); + apply_alternate(app, t.id, (t.byte, t.byte + t.ch.len_utf8()), &t.features, t.has_choice, pick); + } +} + +/// `name` of glyph `gid`, or `gid N`. +fn glyph_label(face: &vectorcraft_text::FontFace, gid: u32) -> String { + face.glyph_name(gid).unwrap_or_else(|| format!("gid {gid}")) +} + +/// The letter to insert in place of character `c`, an Arabic presentation form: the letter whose +/// glyph the font substitutes with `init`, `medi`, `fina` or `isol` to get `c`'s glyph. The +/// presentation form itself is a fixed shape that neither joins its neighbors nor is joined by +/// them; the letter takes the form its place in the word asks for, so the word stays joined. +/// `None` when `c` is not such a form in this font. +fn base_letter(face: &vectorcraft_text::FontFace, c: char, gid: u32) -> Option { + let origin = face.glyph_origin(gid)?; + origin.position()?; + (origin.ch != c).then_some(origin.ch) +} + +/// The tooltip of an encoded glyph (a character): its code point and glyph, and what a +/// double-click inserts. +fn encoded_tip(face: &vectorcraft_text::FontFace, c: char, gid: u32) -> String { + let head = format!("U+{:04X} {c} (glyph {gid})", c as u32); + match base_letter(face, c, gid) { + Some(base) => format!( + "{head}\n{}", + crate::i18n::fmt( + tl!("Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does."), + &[("base", &base.to_string()), ("code", &format!("{:04X}", base as u32))] + ) + ), + None => format!("{head}\n{}", tl!("Double-click to insert")), + } +} + +/// Double-click on character `c` of the grid: insert it, or its letter when it is an Arabic +/// presentation form ([`base_letter`]), which the status says. +fn insert_character(app: &mut VectorcraftApp, ctx: &egui::Context, face: &vectorcraft_text::FontFace, c: char, gid: u32) { + match base_letter(face, c, gid) { + Some(base) => { + insert(app, ctx, base); + app.status(crate::i18n::fmt( + tl!("Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position."), + &[("base", &base.to_string()), ("code", &format!("{:04X}", base as u32))], + )); + } + None => insert(app, ctx, c), + } +} + /// The tooltip of a glyph no character maps (Show › Entire Font): its name from the font, `gid N` /// when it has none, its id, and the feature that gives it, if one does. fn unencoded_tip(face: &vectorcraft_text::FontFace, gid: u32) -> String { @@ -898,12 +1087,17 @@ mod tests { /// Double-click the glyph cell at `at`. fn double_click(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2) { + double_click_in(ctx, app, at, vec2(280.0, 420.0)); + } + + /// Double-click the glyph cell at `at` in a panel `size` large. + fn double_click_in(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2, size: egui::Vec2) { let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; // Time only moves forward in one egui context. let mut time = ctx.input(|i| i.time) + 10.0; let mut timed = |app: &mut VectorcraftApp, events: Vec| { time += 0.05; - let screen = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 420.0))); + let screen = Some(Rect::from_min_size(Pos2::ZERO, size)); let mut out = ctx.run_ui(egui::RawInput { screen_rect: screen, time: Some(time), events, ..Default::default() }, |ui| show(app, ui)); out.textures_delta.clear(); }; @@ -1074,4 +1268,215 @@ mod tests { app.ui.glyphs_preview = 99; assert_eq!(cell_index(&app), CELL_DEFAULT); } + /// The panel tall enough to draw every row of the grid, so each glyph has a cell. + const TALL: egui::Vec2 = vec2(280.0, 8000.0); + + fn tall_shapes(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, TALL)), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes.into_iter().map(|c| c.shape).collect() + } + + /// Each glyph preview with the colour it is drawn in, in the order of the grid. + fn inks(shapes: &[Shape]) -> Vec<(Rect, Color32)> { + fn walk(s: &Shape, out: &mut Vec<(Rect, Color32)>) { + match s { + Shape::Mesh(m) => { + let r = m.calc_bounds(); + if (r.width() - r.height()).abs() < 1.0 && r.width() >= 14.0 { + out.push((r, m.vertices.first().map_or(Color32::TRANSPARENT, |v| v.color))); + } + } + Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = vec![]; + shapes.iter().for_each(|s| walk(s, &mut out)); + out + } + + /// What a double-click on glyph `gid` of `face` would do to the selection ([`Fit::pick`], [`Fit::why`]). + fn verdict(ctx: &egui::Context, app: &VectorcraftApp, face: &vectorcraft_text::FontFace, gid: u32) -> Result { + let mut out = Err(String::new()); + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, TALL)), ..Default::default() }; + let mut frame = ctx.run_ui(raw, |ui| { + let fit = Fit::of(app, ui, face); + out = fit.pick(gid).ok_or_else(|| fit.why(face, gid)); + }); + frame.textures_delta.clear(); + out + } + + /// The cell of glyph `gid` in Show › Entire Font of `face`: the grid lists the characters, then + /// the glyphs no character maps. Its centre, and the colour its glyph is drawn in. + fn entire_font_cell(ctx: &egui::Context, app: &mut VectorcraftApp, face: &vectorcraft_text::FontFace, gid: u32) -> (Pos2, Color32) { + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count(); + let unencoded = face.unencoded_glyphs(); + let cells = inks(&tall_shapes(ctx, app)); + assert_eq!(cells.len(), chars + unencoded.len(), "every glyph has a cell"); + let at = chars + unencoded.iter().position(|g| *g == gid).expect("an unencoded glyph"); + (cells[at].0.center(), cells[at].1) + } + + /// The owner's Kufi Murabba PIIIX Heavy (`~/Library/Fonts`), in the app's font database; `None` + /// when it is not installed. A file that is there but gives no face is a failure. + fn kufi_heavy() -> Option> { + let path = std::path::Path::new(&std::env::var_os("HOME")?).join("Library/Fonts/KufiMurabbaPIIIX-Heavy.ttf"); + if !path.exists() { + eprintln!("skipped: {} is not installed", path.display()); + return None; + } + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{} exists but cannot be read: {e}", path.display())); + FontDb::global().add_font(bytes); + let face = + FontDb::global().face("Kufi Murabba PIIIX", "Heavy").unwrap_or_else(|| panic!("{} exists but its face is missing", path.display())); + assert_eq!((face.family.as_str(), face.style.as_str()), ("Kufi Murabba PIIIX", "Heavy"), "the installed font, not a fallback"); + Some(face) + } + + fn gid_named(face: &vectorcraft_text::FontFace, name: &str) -> u32 { + (0..face.glyph_count()).find(|g| face.glyph_name(*g).as_deref() == Some(name)).unwrap_or_else(|| panic!("{name} missing")) + } + + /// The word al-Khwarizmi ending in `letter`, in Kufi Murabba PIIIX Heavy. + fn khwarizmi_in_heavy(letter: char) -> (VectorcraftApp, serde_json::Value, usize) { + let word = format!("\u{627}\u{644}\u{62E}\u{648}\u{627}\u{631}\u{632}\u{645}{letter}"); + let (mut app, id) = editing(&word); + app.run("text.setRangeStyle", json!({"id": id, "font": "Kufi Murabba PIIIX", "style": "Heavy"})).unwrap(); + (app, id, word.len() - letter.len_utf8()) + } + + /// Applying each alternate of the final letter of a word in Kufi Murabba PIIIX Heavy, with the + /// letter selected as the Type tool selects it (the UTF-8 byte range of the character, the + /// last one of a right-to-left word), by a double-click on its cell in Alternates for Current + /// Selection and in Entire Font: the same glyph, one undo step, the rest of the word as it was. + /// Persian yeh, Arabic yeh and alef maksura each have their own `.fina.alt1` to `.fina.alt6`. + #[test] + fn a_final_letters_alternates_apply_from_alternates_and_from_entire_font() { + let Some(face) = kufi_heavy() else { return }; + for (letter, base) in [('\u{06CC}', "yehfarsi"), ('\u{064A}', "yeh"), ('\u{0649}', "alefmaksura")] { + let (mut app, id, last) = khwarizmi_in_heavy(letter); + let plain = glyph_ids(&app, &id); + let ends = plain.len() - 1; + assert_eq!(plain[ends], gid_named(&face, &format!("{base}.fina")), "{base}: the final form"); + select(&mut app, last, last + letter.len_utf8()); + assert_eq!(selected_char(&app), Some(letter), "{base}: the selection is the last letter"); + let alternates: Vec = (1..=6).map(|n| gid_named(&face, &format!("{base}.fina.alt{n}"))).collect(); + let ctx = egui::Context::default(); + for view in ["Alternates for Current Selection", "Entire Font"] { + for (n, alt) in alternates.iter().enumerate() { + choose(&ctx, &mut app, view); + let undo = app.session.active().unwrap().history.undo.len(); + let at = if view == "Entire Font" { + let (at, ink) = entire_font_cell(&ctx, &mut app, &face, *alt); + assert_eq!(ink, Tokens::get(&ctx).text_strong, "{base} {view}: {alt} is not dimmed for its own letter"); + at + } else { + let cells = squares(&shapes(&ctx, &mut app, vec![])); + assert_eq!(cells.len(), 7, "{base}: the final form and its six alternates"); + cells[n + 1].center() + }; + double_click_in(&ctx, &mut app, at, TALL); + let after = glyph_ids(&app, &id); + let mut want = plain.clone(); + want[ends] = *alt; + assert_eq!(after, want, "{base} {view} alt{}: only the last letter changes; status: {}", n + 1, app.ui.status); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "{base} {view} alt{}: one undo step", n + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), plain, "{base} {view}: undo"); + } + } + } + } + + /// A glyph of another letter is dimmed in Entire Font and a double-click changes nothing: the + /// tooltip's reason names the glyph's letter, where in a word it stands and the selected letter. + #[test] + fn a_glyph_that_cannot_go_on_the_selection_is_dimmed_and_says_why() { + let Some(face) = kufi_heavy() else { return }; + let glyph = gid_named(&face, "yehfarsi.fina.alt2"); + assert_eq!(glyph, 820, "the owner's glyph"); + for (letter, selected) in [('\u{064A}', "ي (U+064A)"), ('\u{0649}', "ى (U+0649)")] { + let (mut app, id, last) = khwarizmi_in_heavy(letter); + let plain = glyph_ids(&app, &id); + select(&mut app, last, last + letter.len_utf8()); + let ctx = egui::Context::default(); + let t = Tokens::get(&ctx); + let why = verdict(&ctx, &app, &face, glyph).err().expect("not applicable"); + assert_eq!(why, format!("yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is {selected}.")); + let (at, ink) = entire_font_cell(&ctx, &mut app, &face, glyph); + assert_eq!(ink, t.text_disabled, "{letter}: dimmed"); + let undo = app.session.active().unwrap().history.undo.len(); + double_click_in(&ctx, &mut app, at, TALL); + assert_eq!(glyph_ids(&app, &id), plain, "{letter}: nothing changes"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + assert_eq!(app.ui.status, why, "the status says why"); + } + // The same glyph on Persian yeh applies. + let (mut app, id, last) = khwarizmi_in_heavy('\u{06CC}'); + select(&mut app, last, last + 2); + let ctx = egui::Context::default(); + let (at, ink) = entire_font_cell(&ctx, &mut app, &face, glyph); + assert_eq!(ink, Tokens::get(&ctx).text_strong); + double_click_in(&ctx, &mut app, at, TALL); + assert_eq!(glyph_ids(&app, &id).last(), Some(&glyph)); + // Nothing selected, a glyph no feature reaches, and a variant of the letter's other form. + select(&mut app, 0, 0); + assert_eq!(verdict(&ctx, &app, &face, glyph).err().as_deref(), Some("Select one character with the Type tool first.")); + let (_, ink) = entire_font_cell(&ctx, &mut app, &face, glyph); + assert_eq!(ink, Tokens::get(&ctx).text_disabled, "dimmed without a selection"); + select(&mut app, last, last + 2); + let medial = gid_named(&face, "yehfarsi.medi.alt1"); + assert_eq!( + verdict(&ctx, &app, &face, medial).err().as_deref(), + Some("yehfarsi.medi.alt1 is not an alternate of ی (U+06CC) as it stands in its word."), + "its own letter, another place in a word" + ); + let unreached = face.unencoded_glyphs().iter().copied().find(|g| face.glyph_origin(*g).is_none() && face.feature_reaching(*g).is_none()); + if let Some(g) = unreached { + let why = verdict(&ctx, &app, &face, g).err().expect("not applicable"); + assert!(why.starts_with("No OpenType feature reaches "), "{why}"); + } + } + + /// A presentation form picked in Entire Font (beh medial, U+FE92) with the caret before the last + /// letter would be a fixed shape that does not join the word: the letter (beh) is inserted, so + /// the word is shaped as if it were typed, and the status says so. + #[test] + fn a_presentation_form_inserts_its_letter_so_the_word_joins() { + let Some(face) = kufi_heavy() else { return }; + let (mut app, id, last) = khwarizmi_in_heavy('\u{06CC}'); + let typed = { + let word = "\u{627}\u{644}\u{62E}\u{648}\u{627}\u{631}\u{632}\u{645}\u{628}\u{6CC}"; + let (mut other, other_id) = editing(word); + other.run("text.setRangeStyle", json!({"id": other_id, "font": "Kufi Murabba PIIIX", "style": "Heavy"})).unwrap(); + glyph_ids(&other, &other_id) + }; + let gid_of = |c| face.chars().into_iter().find(|(ch, _)| *ch == c).expect("the character is in the font").1; + // Typed as the presentation form itself, the same word is shaped differently: it does not join. + let literal = { + let word = "\u{627}\u{644}\u{62E}\u{648}\u{627}\u{631}\u{632}\u{645}\u{FE92}\u{6CC}"; + let (mut other, other_id) = editing(word); + other.run("text.setRangeStyle", json!({"id": other_id, "font": "Kufi Murabba PIIIX", "style": "Heavy"})).unwrap(); + glyph_ids(&other, &other_id) + }; + eprintln!("typed with beh {typed:?}\ntyped with U+FE92 {literal:?}"); + assert_ne!(literal, typed, "the presentation form is not shaped like the letter"); + assert_eq!(base_letter(&face, '\u{FE92}', gid_of('\u{FE92}')), Some('\u{0628}'), "medial beh is a form of beh"); + assert_eq!(base_letter(&face, '\u{0628}', gid_of('\u{0628}')), None, "beh itself inserts as it is"); + let tip = encoded_tip(&face, '\u{FE92}', gid_of('\u{FE92}')); + assert!(tip.contains("(U+0628)"), "{tip}"); + select(&mut app, last, last); + let ctx = egui::Context::default(); + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).collect::>(); + let cells = inks(&tall_shapes(&ctx, &mut app)); + let at = cells[chars.iter().position(|(c, _)| *c == '\u{FE92}').expect("the presentation form is in the font")].0.center(); + double_click_in(&ctx, &mut app, at, TALL); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert!(text.plain_text().ends_with("\u{645}\u{628}\u{6CC}"), "the letter is in the text: {}", text.plain_text()); + assert_eq!(glyph_ids(&app, &id), typed, "shaped as the word typed with beh"); + assert!(app.ui.status.starts_with("Inserted ب (U+0628)"), "{}", app.ui.status); + } } diff --git a/docs/gaps.md b/docs/gaps.md index cb915bd80a..97b3c1fb90 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -372,7 +372,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G4 Advanced type -- **Done (#5514):** the Glyphs panel's Alternates for Current Selection lists the alternates of the glyph the shaper makes for the selected character in its word, and a double-click applies the OpenType feature value that draws one (`salt=2`, `ss03`) to that character alone, in one undo step, so the text stays real Unicode and an Arabic word keeps its joining forms: runs that differ only in their features are shaped as one piece of text. A variant no feature draws there is still drawn as a replacement glyph, which may not join. Show › Entire Font lists the unencoded glyphs by id, with their name and the feature that gives them. Left: alternates reached through contextual or ligature lookups, and `cv01`–`cv99` and `hist`, which have no style tag; estimate unchanged. +- **Done (#5514):** the Glyphs panel's Alternates for Current Selection lists the alternates of the glyph the shaper makes for the selected character in its word, and a double-click applies the OpenType feature value that draws one (`salt=2`, `ss03`) to that character alone, in one undo step, so the text stays real Unicode and an Arabic word keeps its joining forms: runs that differ only in their features are shaped as one piece of text. A variant no feature draws there is still drawn as a replacement glyph, which may not join. Show › Entire Font lists the unencoded glyphs by id, with their name and the feature that gives them; a double-click on one applies it to the selected character when the font gives it as that character's alternate in its word (`yehfarsi.fina.alt2` on a final Persian yeh), and any other is drawn dimmed with a tooltip that says why (it belongs to another letter or place in a word, no feature reaches it, nothing is selected). An Arabic presentation form picked there inserts its letter, so the word still joins. Left: alternates reached through contextual or ligature lookups, and `cv01`–`cv99` and `hist`, which have no style tag; estimate unchanged. - **Fixed:** fullwidth `!` / `?` stay upright in vertical point and area type when a font has no vertical alternate. Existing `vert` substitutions and the rotated fallback for brackets and long vowel marks are preserved; the broader CJK gap and estimate are unchanged. diff --git a/docs/type-parity.md b/docs/type-parity.md index 26a1fdaa07..0483216854 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -31,7 +31,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | | OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet | -| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the feature that draws the variant to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature draws there is drawn as a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. | +| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the feature that draws the variant to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature draws there is drawn as a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. | | Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | @@ -80,7 +80,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| -| 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. | +| 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, applies the ones that are the selected letter's alternates and dims, with the reason, those that are not. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged | From c81f3b4515d4679cf66ccceda4af325efd0347b7 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:31:46 +0430 Subject: [PATCH 05/79] Glyph alternates: read tag=0 as off in the OpenType dialog; one accurate doc statement of how alternates apply --- .../ui-egui/src/dialogs/char_style_options.rs | 32 ++++++++++++++++--- docs/type-parity.md | 4 +-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index 32333848c7..64f492066b 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -189,6 +189,18 @@ fn bare(tag: &str) -> &str { tag.split_once('=').map_or(tag, |(name, _)| name) } +/// Is the feature a tag of a style's list sets on? A `tag=N` of a valued feature is off for 0, +/// which a `-` prefix check misses; every other tag is on unless it starts with `-`. +fn tag_on(tag: &str) -> bool { + let name = bare(tag); + let valued = name == "swsh" || vectorcraft_text::EXTRA_FEATURES.contains(&name); + if valued && vectorcraft_text::OtFeatures::known_tag(tag) { + vectorcraft_text::OtFeatures::from_tags([tag]).feature_value(name).is_some() + } else { + !tag.starts_with('-') + } +} + /// The OpenType Features fields for a style's `features`: each control is unset unless the list /// names one of its tags, on or off. Figure is also unset when the list turns one of its tags on /// or off against every Figure menu entry, for example `pnum` alone. @@ -199,9 +211,9 @@ fn opentype_fields(features: Option<&Value>) -> Map { let mut fields = Map::new(); for (field, tag) in OT_SWITCHES { let last = tags.iter().rev().find(|t| bare(t) == *tag); - fields.insert((*field).into(), last.map_or(Value::Null, |t| json!(!t.starts_with('-')))); + fields.insert((*field).into(), last.map_or(Value::Null, |t| json!(tag_on(t)))); } - let last_on = |tag: &str| tags.iter().rev().find(|t| bare(t) == tag).map(|t| !t.starts_with('-')); + let last_on = |tag: &str| tags.iter().rev().find(|t| bare(t) == tag).map(|t| tag_on(t)); let figure = names(&FIGURE_TAGS) .then(|| FIGURE_VALUES.get(crate::panels::opentype::figure(&f))) .flatten() @@ -212,7 +224,7 @@ fn opentype_fields(features: Option<&Value>) -> Map { fields.insert("ot_position".into(), position.map_or(Value::Null, |v| json!(v))); let sets: Vec = vectorcraft_text::EXTRA_FEATURES[..20] .iter() - .filter_map(|set| tags.iter().rev().find(|t| bare(t) == *set).map(|t| if t.starts_with('-') { format!("-{set}") } else { set.to_string() })) + .filter_map(|set| tags.iter().rev().find(|t| bare(t) == *set).map(|t| if tag_on(t) { set.to_string() } else { format!("-{set}") })) .collect(); fields.insert("ot_sets".into(), if sets.is_empty() { Value::Null } else { json!(sets) }); fields @@ -488,11 +500,11 @@ fn opentype_attrs(d: &Dialog, original: &Map, original_fields: &M if !unchanged("ot_sets") { let sets = &vectorcraft_text::EXTRA_FEATURES[..20]; // A set still on keeps the value its tag gives (`ss01=2`). - let before: Vec = tags.iter().filter(|t| sets.contains(&bare(t)) && !t.starts_with('-')).cloned().collect(); + let before: Vec = tags.iter().filter(|t| sets.contains(&bare(t))).cloned().collect(); tags.retain(|t| !sets.contains(&bare(t))); let listed = d.fields.get("ot_sets").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str); tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(|t| { - let kept = before.iter().rev().find(|b| !t.starts_with('-') && bare(b) == bare(t)); + let kept = before.iter().rev().find(|b| bare(b) == bare(t)).filter(|b| !t.starts_with('-') && tag_on(b)); kept.map_or_else(|| t.to_string(), Clone::clone) })); } @@ -1337,6 +1349,16 @@ mod tests { assert_eq!(edited_attrs(&d).unwrap(), Map::new()); } + #[test] + fn a_stored_zero_value_reads_as_off_and_turning_it_on_saves_it() { + let mut d = dialog_for(json!({"features": ["ss01=0", "salt=0", "ss02=2"]})); + assert_eq!(d.fields["ot_salt"], json!(false)); + assert_eq!(d.fields["ot_sets"], json!(["-ss01", "ss02"])); + d.fields.insert("ot_sets".into(), json!(["ss01", "ss02"])); + d.fields.insert("ot_salt".into(), json!(true)); + assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["salt", "ss01", "ss02=2"])); + } + #[test] fn figure_tags_no_menu_entry_matches_leave_the_figure_unset() { for features in [json!(["pnum"]), json!(["lnum"]), json!(["onum", "lnum"]), json!(["tnum", "pnum"])] { diff --git a/docs/type-parity.md b/docs/type-parity.md index 0483216854..f8c48191a1 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -30,8 +30,8 @@ against Illustrator's public documentation; **estimated** unless noted. | Copy or Cut selected characters, then paste as separate text art | done | A Type-range copy keeps the selected characters' font family, size and color when pasted on the canvas after leaving text editing, in the same document or another. On desktop the clipboard offers plain text to text consumers and rich art for canvas Paste; Paste Without Formatting while editing uses the destination text's style. `tests::clipboard::type_range_copy_stays_styled_when_pasted_as_art` (#5636) | | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | -| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet | -| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the feature that draws the variant to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature draws there is drawn as a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. | +| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, a double-click applies the OpenType feature that draws one (`salt=N`, `ssNN`) to that character alone; a variant no feature reaches is drawn through `text.setGlyphAlternate`, which may not join; Show › Entire Font applies the selected letter's alternates the same way and dims the others with the reason; the glyph-cell pop-up of alternates is not there yet | +| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the OpenType feature that draws the variant (`salt=N` or `ssNN`) to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature reaches falls back to a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. | | Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | From ff6a4be4750acdb9d477912cc3f0384a1d08a4ae Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:06:29 +0430 Subject: [PATCH 06/79] Glyphs: Entire Font applies a glyph no feature reaches to the selected letter as a glyph choice; text tests for ligatures, word probe, candidate cap and aalt order --- crates/engine/src/cmd/textedit.rs | 4 +- crates/text/src/fontdb/alternates.rs | 32 ++++++++ crates/text/src/fontdb/build.rs | 1 + crates/text/src/fontdb/face.rs | 2 + crates/text/src/test_fonts.rs | 43 +++++++++++ crates/text/src/tests/glyph_alternates.rs | 94 +++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 1 - crates/ui-egui/src/i18n/bs.tsv | 1 - crates/ui-egui/src/i18n/ca.tsv | 1 - crates/ui-egui/src/i18n/cs.tsv | 1 - crates/ui-egui/src/i18n/da.tsv | 1 - crates/ui-egui/src/i18n/de.tsv | 1 - crates/ui-egui/src/i18n/el.tsv | 1 - crates/ui-egui/src/i18n/es.tsv | 1 - crates/ui-egui/src/i18n/fi.tsv | 1 - crates/ui-egui/src/i18n/fr.tsv | 1 - crates/ui-egui/src/i18n/hi.tsv | 1 - crates/ui-egui/src/i18n/hu.tsv | 1 - crates/ui-egui/src/i18n/id.tsv | 1 - crates/ui-egui/src/i18n/it.tsv | 1 - crates/ui-egui/src/i18n/ja.tsv | 1 - crates/ui-egui/src/i18n/ka.tsv | 1 - crates/ui-egui/src/i18n/ko.tsv | 1 - crates/ui-egui/src/i18n/nl.tsv | 1 - crates/ui-egui/src/i18n/pl.tsv | 1 - crates/ui-egui/src/i18n/pt-br.tsv | 1 - crates/ui-egui/src/i18n/ru.tsv | 1 - crates/ui-egui/src/i18n/sv.tsv | 1 - crates/ui-egui/src/i18n/tr.tsv | 1 - crates/ui-egui/src/i18n/uk.tsv | 1 - crates/ui-egui/src/i18n/vi.tsv | 1 - crates/ui-egui/src/i18n/zh-hans.tsv | 1 - crates/ui-egui/src/i18n/zh-hant.tsv | 1 - crates/ui-egui/src/panels/glyphs.rs | 74 +++++++++++++----- docs/gaps.md | 2 +- docs/mcp.md | 7 +- docs/type-parity.md | 4 +- 37 files changed, 237 insertions(+), 53 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 6aa546767b..17549f7ddf 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -58,7 +58,7 @@ pub fn specs() -> Vec { "Glyphs", [], None, - "{id, start: byte (a character of it), gid: glyph id|null} draw that one character with another glyph of its font as one undo step, what a double-click in the Glyphs panel's Show › Alternates for Current Selection does: gid is one of the glyphs text.glyphAlternates lists for it; its own default glyph, or null, puts the default glyph back. The text keeps the character, and its other attributes do not change. The choice is saved with the character's style and names the face it was made in (PostScript name and version): set in another font or version, or after the font goes missing, the character takes its default glyph; typed characters take their default glyphs. Choosing the glyph the character already draws is not an edit. An error when the character's font is not available, the glyph is not one of its alternates, or the character's style does not draw it (All Caps, Small Caps, another numeral system) → {id, start, end (the character's bytes), gid (the glyph drawn)}", + "{id, start: byte (a character of it), gid: glyph id|null} draw that one character with another glyph of its font as one undo step, what a double-click in the Glyphs panel's Show › Alternates for Current Selection does: gid is one of the glyphs text.glyphAlternates lists for it, a glyph no character maps and no feature reaches whose name is not another letter's (`a.salt` for a), its own default glyph, or null, puts the default glyph back. The text keeps the character, and its other attributes do not change. The choice is saved with the character's style and names the face it was made in (PostScript name and version): set in another font or version, or after the font goes missing, the character takes its default glyph; typed characters take their default glyphs. Choosing the glyph the character already draws is not an edit. An error when the character's font is not available, the glyph is not one of those, or the character's style does not draw it (All Caps, Small Caps, another numeral system) → {id, start, end (the character's bytes), gid (the glyph drawn)}", has_doc, set_glyph_alternate ), @@ -1294,7 +1294,7 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { let substituted = unchosen.is_some_and(|g| g != default); let choice = match gid.filter(|g| *g != default || substituted) { None => None, - Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) => Some(face.choice(ch, g)), + Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) || face.is_unlisted_alternate(ch, g) => Some(face.choice(ch, g)), Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))), }; let drawn = choice.as_ref().map_or(unchosen.unwrap_or(default), |c| c.gid); diff --git a/crates/text/src/fontdb/alternates.rs b/crates/text/src/fontdb/alternates.rs index d74a55ff64..c39067f0b6 100644 --- a/crates/text/src/fontdb/alternates.rs +++ b/crates/text/src/fontdb/alternates.rs @@ -67,6 +67,38 @@ impl FontFace { .clone() } + /// Is glyph `gid` one no character maps and no feature reaches, whose name does not give it + /// to another letter than `c` (`a.salt` for `a` in a font with no `salt`; `A.salt` is not + /// `a`'s)? Such a glyph is not among [`Self::alternates`], yet it can be drawn for `c` as a + /// glyph choice ([`Self::choice`]). + pub fn is_unlisted_alternate(&self, c: char, gid: u32) -> bool { + self.unencoded_glyphs().binary_search(&gid).is_ok() + && self.feature_reaching(gid).is_none() + && self.glyph_origin(gid).is_none() + && self.name_owner(gid).is_none_or(|owner| owner == c) + } + + /// The character a glyph's name says it belongs to: the one whose glyph carries the name up + /// to its first `.` (`A` for `A.salt`, `s` for `s.ss1`; the lowest character when several map + /// that glyph). `None` when the glyph has no name, or no character maps a glyph of that name. + fn name_owner(&self, gid: u32) -> Option { + let base = self.glyph_name(gid)?; + let base = base.split('.').next().unwrap_or_default(); + let names = self.named_chars.get_or_init(|| { + let Some(font) = self.skrifa() else { return Default::default() }; + let mut by_char: Vec<(char, u32)> = font.charmap().mappings().filter_map(|(c, g)| Some((char::from_u32(c)?, g.to_u32()))).collect(); + by_char.sort_unstable(); + let mut out = std::collections::HashMap::new(); + for (c, g) in by_char { + if let Some(name) = self.glyph_name(g) { + out.entry(name).or_insert(c); + } + } + out + }); + names.get(base).copied() + } + /// How many glyphs the face has (its `maxp` table; 0 when it can't be read). pub fn glyph_count(&self) -> u32 { self.skrifa().and_then(|f| f.maxp().ok()).map_or(0, |m| u32::from(m.num_glyphs())) diff --git a/crates/text/src/fontdb/build.rs b/crates/text/src/fontdb/build.rs index 261e1b2877..ebf2d1eab9 100644 --- a/crates/text/src/fontdb/build.rs +++ b/crates/text/src/fontdb/build.rs @@ -80,6 +80,7 @@ pub(super) fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Opt svg_glyphs, gsub: std::sync::OnceLock::new(), unencoded: std::sync::OnceLock::new(), + named_chars: std::sync::OnceLock::new(), }) } diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index 3e03e5c1da..7be0664895 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -204,6 +204,8 @@ pub struct FontFace { pub(super) gsub: std::sync::OnceLock, /// [`Self::unencoded_glyphs`], read once. pub(super) unencoded: std::sync::OnceLock>, + /// [`Self::name_owner`]'s glyph names to characters, read once. + pub(super) named_chars: std::sync::OnceLock>, } /// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 77929bbee2..b021c493d6 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -735,6 +735,49 @@ pub fn alternates_font(aalt: bool) -> Option> { Some(builder.build()) } +/// The family of [`capped_candidates_font`]. +pub const CAPPED_CANDIDATES_FAMILY: &str = "Captest Sans"; + +/// The bundled Source Sans 3 Regular, its `cmap` kept, with a `GSUB` table for `a` of an `aalt` +/// alternate substitution that lists `b` 512 times (the longest set read: 512 values to try) and a +/// `salt` single substitution that gives `c`. Family [`CAPPED_CANDIDATES_FAMILY`]. `None` when it +/// can't be built. +pub fn capped_candidates_font() -> Option> { + use write_fonts::tables::gsub::{ + AlternateSet, AlternateSubstFormat1, Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList, + }; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let charmap = font.charmap(); + let name = + name_table(&[(1, CAPPED_CANDIDATES_FAMILY), (2, "Regular"), (4, "Captest Sans Regular"), (5, "Version 1.000"), (6, "CaptestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?)); + let a = gid('a')?; + let cover = || CoverageFormat1::new(vec![a]); + let alternates = AlternateSubstFormat1::new(cover().into(), vec![AlternateSet::new(vec![gid('b')?; 512])]); + let salt = SingleSubst::from(SingleSubstFormat2::new(cover().into(), vec![gid('c')?])); + let lookups = SubstitutionLookupList::new(vec![ + SubstitutionLookup::Alternate(Lookup::new(Default::default(), vec![alternates])), + SubstitutionLookup::Single(Lookup::new(Default::default(), vec![salt])), + ]); + let records = + vec![FeatureRecord::new(Tag::new(b"aalt"), Feature::new(None, vec![0])), FeatureRecord::new(Tag::new(b"salt"), Feature::new(None, vec![1]))]; + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0, 1])), vec![]))]); + builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`joining_alternates_font`]. pub const JOINING_ALTERNATES_FAMILY: &str = "Jointest Sans"; diff --git a/crates/text/src/tests/glyph_alternates.rs b/crates/text/src/tests/glyph_alternates.rs index 992dc9f711..6a47a3b0ee 100644 --- a/crates/text/src/tests/glyph_alternates.rs +++ b/crates/text/src/tests/glyph_alternates.rs @@ -346,6 +346,100 @@ fn a_variant_no_feature_draws_has_no_feature_to_apply() { assert!(face.unencoded_glyphs().contains(&u1) && !face.unencoded_glyphs().contains(&face.glyph_for('a'))); } +/// The alternates are listed `aalt` first, then `salt`, the stylistic sets, `swsh` and `cv01`, each +/// glyph once under the first feature that lists it, whichever feature draws it in the text. +#[test] +fn alternates_are_listed_aalt_first_under_the_first_feature_that_lists_them() { + use crate::test_fonts::{alternates_family, alternates_font, unencoded_glyphs}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(alternates_font(true).expect("the test font builds")); + let [u1, u2, u3, u4] = unencoded_glyphs().expect("unencoded glyphs"); + let face = db.face(alternates_family(true), "Regular").expect("the test face"); + let st = CharStyle { font_family: alternates_family(true).into(), size: 100.0, ..CharStyle::default() }; + let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", st), 1).expect("the a"); + let listed: Vec<(u32, [u8; 4], Option)> = found.alternates.iter().map(|a| (a.gid, a.feature, a.apply.clone())).collect(); + let (a, b, c) = (face.glyph_for('a'), face.glyph_for('b'), face.glyph_for('c')); + assert_eq!( + listed, + [ + (a, *b"aalt", Some("aalt".to_string())), + (u1, *b"aalt", Some("swsh=2".to_string())), + (b, *b"aalt", Some("salt".to_string())), + (u2, *b"ss01", Some("ss01".to_string())), + (u4, *b"ss02", Some("ss02".to_string())), + (u3, *b"swsh", Some("swsh".to_string())), + (c, *b"cv01", None), + ], + "aalt's set first, with the default glyph it lists; b is aalt's though salt draws it" + ); +} + +/// A glyph that more than its character makes (the lam-alef ligature) has no alternates of its own +/// there, though the font substitutes it (`salt`). +#[test] +fn a_ligature_has_no_alternates_in_context() { + use crate::test_fonts::{LAM_ALEF_SALT, lam_alef_family, lam_alef_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(lam_alef_font(b"rlig").expect("the test font builds")); + let family = lam_alef_family(b"rlig"); + let face = db.face(family, "Regular").expect("the test face"); + let lam_alef = TextObject::point(Point::ZERO, "\u{0644}\u{0627}", style(family, &[])); + let ligature = g('H'); + assert_eq!( + face.substitutions(ligature).iter().map(|s| s.glyphs.clone()).collect::>(), + [vec![g(LAM_ALEF_SALT)]], + "the font gives the ligature an alternate" + ); + let found = alternates_in_context(&db, &lam_alef, 0).expect("the ligature"); + assert_eq!((found.default, found.current), (ligature, ligature)); + assert!(found.alternates.is_empty(), "{:?}", found.alternates); + // The lam alone is its own character: it has no alternates because the font gives it none. + let alone = alternates_in_context(&db, &TextObject::point(Point::ZERO, "\u{0644}", style(family, &[])), 0).expect("the lam"); + assert_eq!(alone.default, g('l')); +} + +/// A word is shaped as one piece (to 256 characters each side of the selected one), the selected +/// character found in it by its offset: a letter deep in a long word and the letters next to a +/// space, a line feed or a line separator each get the form their place in their own word gives. +#[test] +fn the_probe_finds_the_character_in_its_word_across_a_long_word_and_breaks() { + let db = db(); + let text = |s: &str| TextObject::point(Point::ZERO, s, style(JOINING_ALTERNATES_FAMILY, &[])); + let default_of = |t: &TextObject, byte: usize| { + let found = alternates_in_context(&db, t, byte).expect("a character"); + (found.default, found.alternates.len()) + }; + // 700 letters: beh, 698 teh, noon. Each is found at its own offset, 300 of them past the + // 256 characters either side that are shaped. + let long = text(&format!("{BEH}{}{NOON}", TEH.repeat(698))); + assert_eq!(default_of(&long, 0), (g('B'), 0), "the first letter is initial"); + assert_eq!(default_of(&long, 2 * 300), (g('u'), 3), "a medial teh deep in the word, with its alternates"); + assert_eq!(default_of(&long, 2 * 699), (g('M'), 0), "the last letter is final"); + // Two words: the break leaves each its own initial and final letters. The teh before the break + // ends its word (final) and the one after starts the next (initial), not medial. + for brk in [" ", "\n", "\u{2028}", "\u{2029}"] { + let two = text(&format!("{BEH}{TEH}{brk}{TEH}{NOON}")); + let after = 2 * 2 + brk.len(); + assert_eq!(default_of(&two, 2), (g('U'), 2), "{brk:?}: final teh before the break, with its two alternates"); + assert_eq!(default_of(&two, after), (g('T'), 0), "{brk:?}: initial teh after it"); + assert_eq!(default_of(&two, after + 2), (g('M'), 0), "{brk:?}: the noon after it is final"); + } +} + +/// A word of alternates longer than the candidates tried (512) leaves the features after it +/// untried: `aalt`'s 512 values use them all, so `salt`'s glyph is listed but no feature is applied +/// for it. +#[test] +fn the_candidates_tried_are_capped() { + use crate::test_fonts::{CAPPED_CANDIDATES_FAMILY, capped_candidates_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(capped_candidates_font().expect("the test font builds")); + let face = db.face(CAPPED_CANDIDATES_FAMILY, "Regular").expect("the test face"); + let found = alternates_in_context(&db, &TextObject::point(Point::ZERO, "xay", style(CAPPED_CANDIDATES_FAMILY, &[])), 1).expect("the a"); + let listed: Vec<(u32, Option)> = found.alternates.iter().map(|a| (a.gid, a.apply.clone())).collect(); + assert_eq!(listed, [(face.glyph_for('b'), Some("aalt".to_string())), (face.glyph_for('c'), None)]); +} + /// A feature on a one-byte character (Latin) reaches that character: the range of a feature is /// its source bytes, and harfrust applies a range `a..b` to clusters `a..b - 1` unless the end is /// inclusive, which would leave a one-byte character out. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3539c19742..11abaee21f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -854,7 +854,6 @@ font-technology Variable Променливи at the end of a word в края на дума standing alone когато стои сам {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не е алтернатива на {sel} (U+{code}) там, където е в думата си. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Никоя OpenType функция не извежда {glyph}, затова не може да се приложи към текст. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Двойно щракване за вмъкване на {base} (U+{code}): представителната форма не се свързва, буквата се свързва. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Вмъкнато е {base} (U+{code}) вместо представителна форма: шрифтът я свързва със съседите според позицията. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Нито една OpenType функция не рисува този глиф тук, затова той заменя глифа на знака и може да не се свързва със съседните знаци. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index dd785fc5d5..358129cb03 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1562,7 +1562,6 @@ digits Malayalam Malajalam at the end of a word na kraju riječi standing alone kad stoji sam {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} nije alternativa znaku {sel} (U+{code}) onako kako stoji u svojoj riječi. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Nijedna OpenType funkcija ne daje {glyph}, pa se ne može primijeniti na tekst. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dvostruki klik za umetanje znaka {base} (U+{code}): oblik za prikaz se ne spaja, slovo se spaja. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Umetnut je znak {base} (U+{code}) umjesto oblika za prikaz: font ga spaja sa susjedima prema položaju. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nijedna OpenType funkcija ne crta ovaj glif ovdje, pa on zamjenjuje glif znaka i možda se neće spajati sa susjednim znakovima. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cc106dbafa..42a7b12922 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1216,7 +1216,6 @@ at the end of a word al final d’una paraula standing alone quan va sol {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} no és una alternativa de {sel} (U+{code}) tal com es troba a la seva paraula. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Cap funció OpenType arriba a {glyph}, de manera que no es pot aplicar al text. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Feu doble clic per inserir {base} (U+{code}): una forma de presentació no s’uneix, la lletra sí. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. S’ha inserit {base} (U+{code}) en lloc d’una forma de presentació: el tipus de lletra l’uneix als veïns segons la posició. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Cap funció OpenType dibuixa aquest glif aquí, de manera que substitueix el glif del caràcter i pot no unir-se als caràcters veïns. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c7be3ff0dd..20dd7d45b9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1586,7 +1586,6 @@ digits Malayalam Malajálamské at the end of a word na konci slova standing alone když stojí samostatně {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} není alternativou znaku {sel} (U+{code}) tak, jak stojí ve svém slově. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Žádná funkce OpenType nedává {glyph}, proto ho nelze použít na text. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dvojklikem vložíte {base} (U+{code}): prezentační tvar se nespojuje, písmeno ano. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Vloženo {base} (U+{code}) místo prezentačního tvaru: písmo ho podle polohy spojí se sousedy. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Žádná funkce OpenType tento glyf zde nekreslí, proto nahradí glyf znaku a nemusí se spojit se sousedními znaky. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 681444c936..016da20eb0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1496,7 +1496,6 @@ digits Malayalam Malayalam at the end of a word i slutningen af et ord standing alone når det står alene {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} er ikke et alternativ til {sel} (U+{code}), som det står i sit ord. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Ingen OpenType-funktion giver {glyph}, så det kan ikke anvendes på tekst. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dobbeltklik for at indsætte {base} (U+{code}) i stedet: en præsentationsform forbindes ikke, bogstavet gør. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) er indsat i stedet for en præsentationsform: skrifttypen forbinder det med naboerne efter position. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion tegner dette glyf her, så det erstatter tegnets glyf og forbindes måske ikke med nabotegnene. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 10b78c831d..4b64c8f134 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -848,7 +848,6 @@ font-technology Variable Variabel at the end of a word am Wortende standing alone allein stehend {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} ist keine Alternative zu {sel} (U+{code}), so wie es in seinem Wort steht. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Keine OpenType-Funktion liefert {glyph}, daher lässt es sich nicht auf Text anwenden. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Doppelklicken, um stattdessen {base} (U+{code}) einzufügen: Eine Darstellungsform verbindet sich nicht, der Buchstabe schon. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) statt einer Darstellungsform eingefügt: Die Schrift verbindet es je nach Position mit den Nachbarn. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Keine OpenType-Funktion zeichnet diese Glyphe hier, daher ersetzt sie die Glyphe des Zeichens und verbindet sich möglicherweise nicht mit den Nachbarzeichen. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a222d9274f..247f1d4752 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -827,7 +827,6 @@ font-technology Variable Μεταβλητές at the end of a word στο τέλος μιας λέξης standing alone όταν στέκεται μόνος {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. Ο γλύφος {glyph} δεν είναι εναλλακτικός του {sel} (U+{code}) όπως βρίσκεται στη λέξη του. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Καμία λειτουργία OpenType δεν δίνει τον γλύφο {glyph}, οπότε δεν μπορεί να εφαρμοστεί στο κείμενο. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Διπλό κλικ για εισαγωγή του {base} (U+{code}): μια μορφή παρουσίασης δεν συνδέεται, το γράμμα συνδέεται. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Εισήχθη το {base} (U+{code}) αντί για μορφή παρουσίασης: η γραμματοσειρά το συνδέει με τους γείτονες ανάλογα με τη θέση. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Καμία λειτουργία OpenType δεν σχεδιάζει αυτόν τον γλύφο εδώ, οπότε αντικαθιστά τον γλύφο του χαρακτήρα και μπορεί να μην συνδέεται με τους γειτονικούς χαρακτήρες. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8d93dfbbe5..83e14cfa32 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -869,7 +869,6 @@ font-technology Variable Variables at the end of a word al final de una palabra standing alone cuando está solo {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} no es una alternativa de {sel} (U+{code}) tal como está en su palabra. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Ninguna función OpenType llega a {glyph}, así que no se puede aplicar al texto. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Doble clic para insertar {base} (U+{code}) en su lugar: una forma de presentación no se une, la letra sí. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Se insertó {base} (U+{code}) en lugar de una forma de presentación: la fuente la une a sus vecinas según la posición. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ninguna función OpenType dibuja este glifo aquí, así que sustituye el glifo del carácter y puede que no se una a los caracteres vecinos. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 0673e39ce8..1bc6558136 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1559,7 +1559,6 @@ digits Malayalam Malajalam at the end of a word sanan lopussa standing alone yksinään {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} ei ole merkin {sel} (U+{code}) vaihtoehto sellaisena kuin se on sanassaan. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Mikään OpenType-ominaisuus ei tuota glyfiä {glyph}, joten sitä ei voi käyttää tekstiin. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Lisää kaksoisnapsauttamalla sen sijaan {base} (U+{code}): esitysmuoto ei liity, kirjain liittyy. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Lisättiin {base} (U+{code}) esitysmuodon sijaan: fontti liittää sen naapureihin sijainnin mukaan. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Mikään OpenType-ominaisuus ei piirrä tätä glyfiä tässä, joten se korvaa merkin glyfin eikä välttämättä liity viereisiin merkkeihin. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index fb5619bb2a..299af68266 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -872,7 +872,6 @@ font-technology Variable Variables at the end of a word à la fin d’un mot standing alone isolé {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} n’est pas une variante de {sel} (U+{code}) tel qu’il se trouve dans son mot. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Aucune fonction OpenType n’atteint {glyph} ; il ne peut donc pas être appliqué au texte. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Double-cliquez pour insérer {base} (U+{code}) à la place : une forme de présentation ne se lie pas, la lettre si. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) a été inséré à la place d’une forme de présentation : la police le lie à ses voisins selon la position. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Aucune fonction OpenType ne dessine ce glyphe ici : il remplace le glyphe du caractère et peut ne pas se lier aux caractères voisins. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 5274287e32..0d50845ab0 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1352,7 +1352,6 @@ at the end of a word शब्द के अंत में standing alone अकेले खड़े होने पर {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}, {sel} (U+{code}) का विकल्प नहीं है, जैसा वह अपने शब्द में खड़ा है। - No OpenType feature reaches {glyph}, so it cannot be applied to text. कोई OpenType फ़ीचर {glyph} तक नहीं पहुँचता, इसलिए इसे टेक्स्ट पर लागू नहीं किया जा सकता। Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. इसके बजाय {base} (U+{code}) सम्मिलित करने के लिए डबल क्लिक करें: प्रेज़ेंटेशन फ़ॉर्म नहीं जुड़ता, अक्षर जुड़ता है। Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. प्रेज़ेंटेशन फ़ॉर्म की जगह {base} (U+{code}) सम्मिलित किया गया: फ़ॉन्ट उसे स्थिति के अनुसार पड़ोसियों से जोड़ता है। No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. यहाँ कोई OpenType फ़ीचर इस ग्लिफ़ को नहीं बनाता, इसलिए यह कैरेक्टर के ग्लिफ़ की जगह लेता है और पड़ोसी कैरेक्टरों से जुड़ नहीं भी सकता। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b89d479ef6..1ac792288e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1122,7 +1122,6 @@ font-technology Variable Változó at the end of a word szó végén standing alone önállóan állva {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. A(z) {glyph} nem változata a(z) {sel} (U+{code}) karakternek úgy, ahogy a szavában áll. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Egyetlen OpenType-funkció sem éri el a(z) {glyph} glifát, ezért nem alkalmazható a szövegre. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dupla kattintással ezt szúrod be helyette: {base} (U+{code}); a megjelenítési forma nem kapcsolódik, a betű igen. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. A megjelenítési forma helyett ez lett beszúrva: {base} (U+{code}); a betűtípus pozíció szerint kapcsolja a szomszédokhoz. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Itt egyetlen OpenType-funkció sem rajzolja meg ezt a glifát, ezért a karakter glifáját váltja fel, és előfordulhat, hogy nem kapcsolódik a szomszédos karakterekhez. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 64d77d1617..2e58b21911 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1496,7 +1496,6 @@ digits Malayalam Malayalam at the end of a word di akhir kata standing alone saat berdiri sendiri {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} bukan alternatif {sel} (U+{code}) sebagaimana posisinya di dalam katanya. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Tidak ada fitur OpenType yang menghasilkan {glyph}, jadi tidak bisa diterapkan ke teks. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Klik ganda untuk menyisipkan {base} (U+{code}) sebagai gantinya: bentuk presentasi tidak menyambung, hurufnya menyambung. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) disisipkan sebagai ganti bentuk presentasi: font menyambungkannya ke huruf di sebelahnya menurut posisi. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Tidak ada fitur OpenType yang menggambar glif ini di sini, jadi glif ini menggantikan glif karakter dan mungkin tidak menyambung dengan karakter di sebelahnya. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8b9d0331f7..0d10bede2b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -869,7 +869,6 @@ font-technology Variable Variabili at the end of a word alla fine di una parola standing alone da solo {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} non è un’alternativa di {sel} (U+{code}) così come si trova nella sua parola. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Nessuna funzione OpenType raggiunge {glyph}, quindi non può essere applicato al testo. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Fai doppio clic per inserire invece {base} (U+{code}): una forma di presentazione non si unisce, la lettera sì. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Inserito {base} (U+{code}) al posto di una forma di presentazione: il font lo unisce ai vicini in base alla posizione. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nessuna funzione OpenType disegna questo glifo qui: sostituisce il glifo del carattere e potrebbe non unirsi ai caratteri vicini. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5a3de37329..510e544c8e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1432,7 +1432,6 @@ digits Malayalam マラヤーラム数字 at the end of a word 語末 standing alone 単独のとき {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} は、{sel} (U+{code}) が単語の中で置かれている位置での異体字ではありません。 - No OpenType feature reaches {glyph}, so it cannot be applied to text. {glyph} に到達する OpenType フィーチャーはないため、テキストに適用できません。 Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. ダブルクリックで代わりに {base} (U+{code}) を挿入: 表示形は前後とつながりませんが、文字はつながります。 Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 表示形の代わりに {base} (U+{code}) を挿入しました。フォントが位置に応じて前後の文字とつなげます。 No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. このグリフを表示する OpenType フィーチャーがないため、文字のグリフが置き換わり、前後の文字とつながらないことがあります。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 9d48ca867a..a9c3648b72 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1422,7 +1422,6 @@ digits Malayalam მალაიალამური at the end of a word სიტყვის ბოლოს standing alone როცა ცალკე დგას {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} არ არის {sel} (U+{code})-ის ალტერნატივა იმ სახით, როგორც ის თავის სიტყვაშია. - No OpenType feature reaches {glyph}, so it cannot be applied to text. არც ერთი OpenType ფუნქცია არ იძლევა {glyph}-ს, ამიტომ მისი ტექსტზე გამოყენება შეუძლებელია. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. ორჯერ დაწკაპეთ ამის ნაცვლად {base} (U+{code}) ჩასასმელად: გამოსახვის ფორმა არ უკავშირდება, ასო უკავშირდება. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. გამოსახვის ფორმის ნაცვლად ჩასმულია {base} (U+{code}): შრიფტი მას მეზობლებს პოზიციის მიხედვით აკავშირებს. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. აქ არც ერთი OpenType ფუნქცია არ ხატავს ამ გლიფს, ამიტომ ის ანაცვლებს სიმბოლოს გლიფს და შეიძლება არ დაუკავშირდეს მეზობელ სიმბოლოებს. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e23a535e28..e78b69e58c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1422,7 +1422,6 @@ digits Malayalam 말라얄람어 at the end of a word 단어 끝 standing alone 단독일 때 {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}은(는) 단어 안에 있는 {sel}(U+{code})의 대체 글리프가 아닙니다. - No OpenType feature reaches {glyph}, so it cannot be applied to text. {glyph}에 도달하는 OpenType 기능이 없으므로 텍스트에 적용할 수 없습니다. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 더블클릭하여 대신 {base}(U+{code}) 삽입: 표시 형태는 연결되지 않지만 글자는 연결됩니다. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 표시 형태 대신 {base}(U+{code})을(를) 삽입했습니다. 글꼴이 위치에 따라 이웃 문자와 연결합니다. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 이 글리프를 그리는 OpenType 기능이 없으므로 문자의 글리프를 대체하며 이웃 문자와 연결되지 않을 수 있습니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c352a8184b..ebd19dd101 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1510,7 +1510,6 @@ digits Malayalam Malayalam at the end of a word aan het einde van een woord standing alone los staand {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} is geen alternatief voor {sel} (U+{code}) zoals het in zijn woord staat. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Geen enkele OpenType-functie levert {glyph}, dus het kan niet op tekst worden toegepast. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dubbelklik om in plaats daarvan {base} (U+{code}) in te voegen: een presentatievorm sluit niet aan, de letter wel. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) ingevoegd in plaats van een presentatievorm: het lettertype verbindt het naar positie met de buren. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Geen enkele OpenType-functie tekent deze glyph hier, dus hij vervangt de glyph van het teken en sluit mogelijk niet aan op de buurtekens. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 93e96b79d6..9667fa5d57 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1499,7 +1499,6 @@ digits Malayalam Malajalam at the end of a word na końcu wyrazu standing alone gdy stoi osobno {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} nie jest alternatywą znaku {sel} (U+{code}) w takiej postaci, w jakiej stoi w swoim wyrazie. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Żadna funkcja OpenType nie daje glifu {glyph}, więc nie można go zastosować do tekstu. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Kliknij dwukrotnie, aby wstawić zamiast tego {base} (U+{code}): forma prezentacyjna się nie łączy, litera tak. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Wstawiono {base} (U+{code}) zamiast formy prezentacyjnej: czcionka łączy go z sąsiadami zależnie od pozycji. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Żadna funkcja OpenType nie rysuje tutaj tego glifu, więc zastępuje on glif znaku i może nie łączyć się z sąsiednimi znakami. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 05b84eb732..39ab094bfc 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -779,7 +779,6 @@ at the end of a word no fim de uma palavra standing alone quando está sozinho {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} não é uma alternativa de {sel} (U+{code}) como ele está em sua palavra. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Nenhum recurso OpenType chega a {glyph}, então ele não pode ser aplicado ao texto. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Clique duplo para inserir {base} (U+{code}) no lugar: uma forma de apresentação não se une, a letra sim. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) foi inserido no lugar de uma forma de apresentação: a fonte o une aos vizinhos conforme a posição. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Nenhum recurso OpenType desenha este glifo aqui, então ele substitui o glifo do caractere e pode não se unir aos caracteres vizinhos. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f7bab2610e..c53e9a3946 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -867,7 +867,6 @@ font-technology Variable Вариативные at the end of a word в конце слова standing alone в отдельном виде {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не является альтернативой символа {sel} (U+{code}) в том виде, как он стоит в своём слове. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Ни одна функция OpenType не даёт {glyph}, поэтому его нельзя применить к тексту. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Дважды щёлкните, чтобы вставить вместо него {base} (U+{code}): форма представления не соединяется, буква соединяется. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Вместо формы представления вставлено {base} (U+{code}): шрифт соединяет его с соседними по положению. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ни одна функция OpenType не рисует здесь этот глиф, поэтому он заменяет глиф символа и может не соединяться с соседними символами. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e7a42b0a03..532489cd11 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -856,7 +856,6 @@ font-technology Variable Variabla at the end of a word i slutet av ett ord standing alone när det står ensamt {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} är inget alternativ till {sel} (U+{code}) som det står i sitt ord. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Ingen OpenType-funktion ger {glyph}, så den kan inte användas på text. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Dubbelklicka för att infoga {base} (U+{code}) i stället: en presentationsform ansluter inte, bokstaven gör det. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. {base} (U+{code}) infogades i stället för en presentationsform: typsnittet ansluter det till grannarna efter position. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Ingen OpenType-funktion ritar den här glyfen här, så den ersätter tecknets glyf och ansluter kanske inte till grannteckenen. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4d286f83d8..3f69ea6555 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -848,7 +848,6 @@ font-technology Variable Değişken at the end of a word kelimenin sonunda standing alone tek başına dururken {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph}, kendi kelimesinde durduğu hâliyle {sel} (U+{code}) karakterinin alternatifi değil. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Hiçbir OpenType özelliği {glyph} glifine ulaşmaz; bu yüzden metne uygulanamaz. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Bunun yerine {base} (U+{code}) eklemek için çift tıklayın: sunum biçimi bağlanmaz, harf bağlanır. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Sunum biçimi yerine {base} (U+{code}) eklendi: yazı tipi onu konumuna göre komşularına bağlar. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Hiçbir OpenType özelliği bu glifi burada çizmez; bu yüzden karakterin glifinin yerine geçer ve komşu karakterlere bağlanmayabilir. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 094b517f0b..61a891e021 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -863,7 +863,6 @@ font-technology Variable Варіативні at the end of a word наприкінці слова standing alone в окремому вигляді {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} не є альтернативою символу {sel} (U+{code}) у тому вигляді, в якому він стоїть у своєму слові. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Жодна функція OpenType не дає {glyph}, тому його не можна застосувати до тексту. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Двічі клацніть, щоб вставити замість нього {base} (U+{code}): форма подання не з’єднується, літера з’єднується. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Замість форми подання вставлено {base} (U+{code}): шрифт з’єднує його із сусідніми за положенням. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Жодна функція OpenType не малює тут цей гліф, тому він замінює гліф символу й може не з’єднуватися із сусідніми символами. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d06d1368f8..69e3a248b4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1007,7 +1007,6 @@ font-technology Variable Biến thiên at the end of a word ở cuối từ standing alone khi đứng riêng {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} không phải glyph thay thế của {sel} (U+{code}) như ký tự đó đang đứng trong từ của nó. - No OpenType feature reaches {glyph}, so it cannot be applied to text. Không có tính năng OpenType nào đạt tới {glyph}, nên không thể áp dụng cho văn bản. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. Bấm đúp để chèn {base} (U+{code}) thay thế: dạng trình bày không nối, còn chữ cái thì nối. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. Đã chèn {base} (U+{code}) thay cho dạng trình bày: phông chữ nối nó với các ký tự bên cạnh theo vị trí. No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. Không có tính năng OpenType nào vẽ glyph này ở đây, nên nó thay glyph của ký tự và có thể không nối với các ký tự bên cạnh. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index dffc014d0d..e6b123b96a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -811,7 +811,6 @@ at the end of a word 位于词尾 standing alone 单独出现时 {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} 不是 {sel}(U+{code})在其单词中所处位置的替代字。 - No OpenType feature reaches {glyph}, so it cannot be applied to text. 没有任何 OpenType 功能能得到 {glyph},因此无法应用于文本。 Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 双击以改为插入 {base}(U+{code}):表现形式不会连接,字母会连接。 Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 已插入 {base}(U+{code})以代替表现形式:字体会按位置将其与相邻字符连接。 No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 没有任何 OpenType 功能会在此处绘制该字形,因此它会替换该字符的字形,并且可能无法与相邻字符连接。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d581b8963d..5cce7a89d4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -831,7 +831,6 @@ font-technology Variable 可變 at the end of a word 位於詞尾 standing alone 單獨出現時 {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} 不是 {sel}(U+{code})在其單字中所處位置的替代字。 - No OpenType feature reaches {glyph}, so it cannot be applied to text. 沒有任何 OpenType 功能能得到 {glyph},因此無法套用到文字。 Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. 按兩下以改為插入 {base}(U+{code}):表現形式不會連接,字母會連接。 Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. 已插入 {base}(U+{code})以取代表現形式:字體會依位置將其與相鄰字元連接。 No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. 沒有任何 OpenType 功能會在此處繪製該字形,因此它會取代該字元的字形,且可能無法與相鄰字元連接。 diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 80163af8bc..66954b5d1b 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -292,7 +292,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().rect_stroke(rect.shrink(0.5), 0, edge, StrokeKind::Inside); // A glyph no character maps is dimmed when it cannot go on the selection. let pick = match (c, &fit) { - (None, Some(fit)) => Some(fit.pick(gid)), + (None, Some(fit)) => Some(fit.pick(&face, gid)), _ => None, }; let ink = if matches!(pick, Some(None)) { t.text_disabled } else { t.text_strong }; @@ -585,20 +585,24 @@ impl Fit { /// What a double-click on glyph `gid` does to the selected character: the way to draw it with /// that glyph, when it is the character's default glyph or one of its alternates as it stands - /// in its word, in the face the selected text is set in. `None` when it is not. - fn pick(&self, gid: u32) -> Option { + /// in its word, in the face the selected text is set in; or a glyph no feature reaches that + /// the font's naming does not give to another letter (`A.salt` for `A`, `s.ss1` in a font + /// with no `ss01`), which is drawn as a glyph choice. `None` when it is not. + fn pick(&self, face: &vectorcraft_text::FontFace, gid: u32) -> Option { let t = self.target.as_ref().filter(|t| t.available)?; let found = t.found.as_ref().filter(|f| f.font_id == self.face_id)?; if gid == found.default { return Some(Pick::Default); } - let a = found.alternates.iter().find(|a| a.gid == gid)?; - Some(a.apply.clone().map_or(Pick::Glyph(gid), Pick::Feature)) + if let Some(a) = found.alternates.iter().find(|a| a.gid == gid) { + return Some(a.apply.clone().map_or(Pick::Glyph(gid), Pick::Feature)); + } + face.is_unlisted_alternate(t.ch, gid).then_some(Pick::Glyph(gid)) } /// Why [`Self::pick`] finds nothing for glyph `gid` of `face`, in the user's words: what the /// glyph belongs to (its letter and place in a word, from the font's own substitutions) and what - /// the selection is, that no feature reaches it, or that nothing is selected. + /// the selection is, or that nothing is selected. fn why(&self, face: &vectorcraft_text::FontFace, gid: u32) -> String { let Some(t) = &self.target else { return tl!("Select one character with the Type tool first.").to_string() }; if !t.available { @@ -619,12 +623,7 @@ impl Fit { &[("glyph", &name), ("sel", &sel), ("code", &sel_code)], ) }; - let Some(origin) = face.glyph_origin(gid) else { - return match face.feature_reaching(gid) { - Some(_) => not_alternate(), - None => crate::i18n::fmt(tl!("No OpenType feature reaches {glyph}, so it cannot be applied to text."), &[("glyph", &name)]), - }; - }; + let Some(origin) = face.glyph_origin(gid) else { return not_alternate() }; let place = match origin.position() { Some(vectorcraft_text::JoiningPosition::Initial) => Some(tl!("at the start of a word")), Some(vectorcraft_text::JoiningPosition::Medial) => Some(tl!("in the middle of a word")), @@ -1303,7 +1302,7 @@ mod tests { let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, TALL)), ..Default::default() }; let mut frame = ctx.run_ui(raw, |ui| { let fit = Fit::of(app, ui, face); - out = fit.pick(gid).ok_or_else(|| fit.why(face, gid)); + out = fit.pick(face, gid).ok_or_else(|| fit.why(face, gid)); }); frame.textures_delta.clear(); out @@ -1422,7 +1421,7 @@ mod tests { assert_eq!(ink, Tokens::get(&ctx).text_strong); double_click_in(&ctx, &mut app, at, TALL); assert_eq!(glyph_ids(&app, &id).last(), Some(&glyph)); - // Nothing selected, a glyph no feature reaches, and a variant of the letter's other form. + // Nothing selected, and a variant of the letter's other form. select(&mut app, 0, 0); assert_eq!(verdict(&ctx, &app, &face, glyph).err().as_deref(), Some("Select one character with the Type tool first.")); let (_, ink) = entire_font_cell(&ctx, &mut app, &face, glyph); @@ -1434,11 +1433,50 @@ mod tests { Some("yehfarsi.medi.alt1 is not an alternate of ی (U+06CC) as it stands in its word."), "its own letter, another place in a word" ); - let unreached = face.unencoded_glyphs().iter().copied().find(|g| face.glyph_origin(*g).is_none() && face.feature_reaching(*g).is_none()); - if let Some(g) = unreached { - let why = verdict(&ctx, &app, &face, g).err().expect("not applicable"); - assert!(why.starts_with("No OpenType feature reaches "), "{why}"); + } + + /// A glyph no feature reaches (`a.a`, `a.b` of a font with no feature for them) is applied by + /// Entire Font to the selected character as a glyph choice, not dimmed; one the font's naming + /// gives to another letter (`a.a` on `y`) stays dimmed, and so does every glyph with no + /// character selected. + #[test] + fn entire_font_applies_a_glyph_no_feature_reaches_to_its_letter() { + use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; + FontDb::global().add_font(alternates_font(true).expect("the test font builds")); + let face = FontDb::global().face(alternates_family(true), "Regular").expect("the test face"); + let glyph = gid_named(&face, "a.a"); + assert!(face.feature_reaching(glyph).is_none() && face.glyph_origin(glyph).is_none(), "no feature reaches it"); + let (mut app, id) = editing("xay"); + app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap(); + let ctx = egui::Context::default(); + let t = Tokens::get(&ctx); + let plain = glyph_ids(&app, &id); + // The cell of the glyph and its colour: this font's grid is longer than the panel is tall, + // but the glyph is in the first rows. + let cell_of = |ctx: &egui::Context, app: &mut VectorcraftApp| { + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count(); + let at = chars + face.unencoded_glyphs().iter().position(|g| *g == glyph).expect("an unencoded glyph"); + let cells = inks(&tall_shapes(ctx, app)); + (cells[at].0.center(), cells[at].1) + }; + // Another letter, and nothing selected: dimmed, and a double-click changes nothing. + for (start, end) in [(2, 3), (0, 0)] { + select(&mut app, start, end); + assert!(verdict(&ctx, &app, &face, glyph).is_err()); + let (at, ink) = cell_of(&ctx, &mut app); + assert_eq!(ink, t.text_disabled, "{start}..{end}: dimmed"); + double_click_in(&ctx, &mut app, at, TALL); + assert_eq!(glyph_ids(&app, &id), plain); } + // The letter itself: enabled, one undo step, drawn with that glyph. + select(&mut app, 1, 2); + assert!(matches!(verdict(&ctx, &app, &face, glyph), Ok(Pick::Glyph(g)) if g == glyph)); + let (at, ink) = cell_of(&ctx, &mut app); + assert_eq!(ink, t.text_strong); + let undo = app.session.active().unwrap().history.undo.len(); + double_click_in(&ctx, &mut app, at, TALL); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + assert_eq!(glyph_ids(&app, &id), [plain[0], glyph, plain[2]]); } /// A presentation form picked in Entire Font (beh medial, U+FE92) with the caret before the last diff --git a/docs/gaps.md b/docs/gaps.md index 97b3c1fb90..0e6c38b666 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -372,7 +372,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G4 Advanced type -- **Done (#5514):** the Glyphs panel's Alternates for Current Selection lists the alternates of the glyph the shaper makes for the selected character in its word, and a double-click applies the OpenType feature value that draws one (`salt=2`, `ss03`) to that character alone, in one undo step, so the text stays real Unicode and an Arabic word keeps its joining forms: runs that differ only in their features are shaped as one piece of text. A variant no feature draws there is still drawn as a replacement glyph, which may not join. Show › Entire Font lists the unencoded glyphs by id, with their name and the feature that gives them; a double-click on one applies it to the selected character when the font gives it as that character's alternate in its word (`yehfarsi.fina.alt2` on a final Persian yeh), and any other is drawn dimmed with a tooltip that says why (it belongs to another letter or place in a word, no feature reaches it, nothing is selected). An Arabic presentation form picked there inserts its letter, so the word still joins. Left: alternates reached through contextual or ligature lookups, and `cv01`–`cv99` and `hist`, which have no style tag; estimate unchanged. +- **Done (#5514):** the Glyphs panel's Alternates for Current Selection lists the alternates of the glyph the shaper makes for the selected character in its word, and a double-click applies the OpenType feature value that draws one (`salt=2`, `ss03`) to that character alone, in one undo step, so the text stays real Unicode and an Arabic word keeps its joining forms: runs that differ only in their features are shaped as one piece of text. A variant no feature draws there is still drawn as a replacement glyph, which may not join. Show › Entire Font lists the unencoded glyphs by id, with their name and the feature that gives them; a double-click on one applies it to the selected character when the font gives it as that character's alternate in its word (`yehfarsi.fina.alt2` on a final Persian yeh), and a glyph no feature reaches whose name does not belong to another letter (`A.salt`, `s.ss1` in a font with no such feature) is applied to the selected character as a glyph choice, which may not join; any other is drawn dimmed with a tooltip that says why (it belongs to another letter or place in a word, nothing is selected). An Arabic presentation form picked there inserts its letter, so the word still joins. Left: alternates reached through contextual or ligature lookups, and `cv01`–`cv99` and `hist`, which have no style tag; estimate unchanged. - **Fixed:** fullwidth `!` / `?` stay upright in vertical point and area type when a font has no vertical alternate. Existing `vert` substitutions and the rotated fallback for brackets and long vowel marks are preserved; the broader CJK gap and estimate are unchanged. diff --git a/docs/mcp.md b/docs/mcp.md index 238bd07ce8..14b601aee7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5735,13 +5735,14 @@ drawn now, and `alternates` the glyphs the font's `GSUB` single and alternate su glyph: `aalt` first, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and `titl`, in lookup order within each, each glyph once. The default glyph is listed only when `aalt` lists it. Ligatures and contextual substitutions are not listed. `text.setGlyphAlternate {id, start, gid}` draws the character with glyph -`gid`, one of those alternates or the default glyph, as one undo step; `gid: null` takes the choice away, and the +`gid`, one of those alternates, a glyph that no character maps and no feature reaches whose name is not another letter's +(`a.salt` for `a`, in a font with no `salt`), or the default glyph, as one undo step; `gid: null` takes the choice away, and the character is drawn as its style draws it (a feature such as `salt` can draw another glyph than the default). The text keeps the character and its other attributes, and characters typed next to it take their default glyphs. The choice is saved with the character's style as `glyphAlternate: {ch, gid, font, version}`, the face's PostScript name and version string: set in another font or version, or with the font missing, the character takes its default glyph. Both commands -refuse a character whose font is not available, and `text.setGlyphAlternate` refuses a glyph that is not one of the -character's alternates and a glyph that the character's style does not draw (All Caps, Small Caps or another numeral +refuse a character whose font is not available, and `text.setGlyphAlternate` refuses a glyph that is not one of those +and a glyph that the character's style does not draw (All Caps, Small Caps or another numeral system). Choosing the glyph a character already draws is not an edit. Character styles do not take `glyphAlternate`: `charStyle.new`, `charStyle.edit` and `charStyle.setAttrs` refuse it. diff --git a/docs/type-parity.md b/docs/type-parity.md index f8c48191a1..b0a9d2edaa 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -31,7 +31,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | | OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, a double-click applies the OpenType feature that draws one (`salt=N`, `ssNN`) to that character alone; a variant no feature reaches is drawn through `text.setGlyphAlternate`, which may not join; Show › Entire Font applies the selected letter's alternates the same way and dims the others with the reason; the glyph-cell pop-up of alternates is not there yet | -| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the OpenType feature that draws the variant (`salt=N` or `ssNN`) to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature reaches falls back to a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. | +| Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, applies one no feature reaches (`A.salt` in a font with no `salt`) to the selected character as a glyph choice (it may not join), and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the OpenType feature that draws the variant (`salt=N` or `ssNN`) to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature reaches falls back to a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. | | Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | @@ -80,7 +80,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| -| 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, applies the ones that are the selected letter's alternates and dims, with the reason, those that are not. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. | +| 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, applies the ones that are the selected letter's alternates, or that no feature reaches and the font's names do not give to another letter, and dims, with the reason, those that are not. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged | From 699073d66ed4dfc5a5344da8bd125e5c11ad2479 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:11:14 +0430 Subject: [PATCH 07/79] Glyphs: accept positional glyph choices and tighten reviewed style values --- crates/engine/src/cmd/textedit.rs | 10 ++++- crates/text/src/test_fonts.rs | 14 ++++--- .../ui-egui/src/dialogs/char_style_options.rs | 9 ++++- crates/ui-egui/src/panels/glyphs.rs | 40 ++++++++++++++++++- docs/gaps.md | 1 + docs/target-app-parity.md | 5 ++- docs/type-parity.md | 5 ++- 7 files changed, 70 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 018a0a5b70..1b98d607a8 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1299,7 +1299,15 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { let substituted = unchosen.is_some_and(|g| g != default); let choice = match gid.filter(|g| *g != default || substituted) { None => None, - Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) || face.is_unlisted_alternate(ch, g) => Some(face.choice(ch, g)), + Some(g) + if g == default + || face.alternates(ch).iter().any(|a| a.gid == g) + || face.is_unlisted_alternate(ch, g) + || vectorcraft_text::alternates_in_context(vectorcraft_text::FontDb::global(), &t, bytes.start) + .is_some_and(|f| f.font_id == face.id() && f.alternates.iter().any(|a| a.gid == g)) => + { + Some(face.choice(ch, g)) + } Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))), }; let drawn = choice.as_ref().map_or(unchosen.unwrap_or(default), |c| c.gid); diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index c17ba25248..4e4f927e7a 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -810,8 +810,9 @@ pub const JOINING_LETTERS: [char; 4] = ['\u{0628}', '\u{062A}', '\u{0646}', '\u{ /// alternate substitutions of the medial teh u (w, x, y: three alternates), of the final teh U /// (W, X), of the isolated teh t (E) and of the medial beh v (z), and `ss01` single substitutions /// u to w, U to W, t to E and v to z. A set differs by position, as in a font that draws each -/// letter's alternates for the position they join in. Family [`JOINING_ALTERNATES_FAMILY`]. `None` -/// when it can't be built. +/// letter's alternates for the position they join in. `cv01` gives the medial noon m the glyph d, +/// an alternate that has no style tag. Family [`JOINING_ALTERNATES_FAMILY`]. `None` when it can't +/// be built. pub fn joining_alternates_font() -> Option> { use write_fonts::tables::cmap::Cmap; use write_fonts::tables::gsub::{ @@ -857,20 +858,21 @@ pub fn joining_alternates_font() -> Option> { vec![AlternateSubstFormat1::new(CoverageFormat1::new(from).into(), sets)], ))) }; - // Lookups: 0 init, 1 medi, 2 fina, 3 salt, 4 ss01. + // Lookups: 0 init, 1 medi, 2 fina, 3 salt, 4 ss01, 5 cv01. let lookups = SubstitutionLookupList::new(vec![ single(&[('b', 'B'), ('t', 'T'), ('n', 'N')])?, single(&[('b', 'v'), ('t', 'u'), ('n', 'm')])?, single(&[('b', 'V'), ('t', 'U'), ('n', 'M'), ('I', 'A')])?, alternates(&[('u', &['w', 'x', 'y']), ('U', &['W', 'X']), ('t', &['E']), ('v', &['z'])])?, single(&[('u', 'w'), ('U', 'W'), ('t', 'E'), ('v', 'z')])?, + single(&[('m', 'd')])?, ]); - // Features sorted by tag, as OpenType requires: fina, init, medi, salt, ss01. - let records: Vec = [(b"fina", 2), (b"init", 0), (b"medi", 1), (b"salt", 3), (b"ss01", 4)] + // Features sorted by tag, as OpenType requires: cv01, fina, init, medi, salt, ss01. + let records: Vec = [(b"cv01", 5), (b"fina", 2), (b"init", 0), (b"medi", 1), (b"salt", 3), (b"ss01", 4)] .into_iter() .map(|(tag, lookup)| FeatureRecord::new(Tag::new(tag), Feature::new(None, vec![lookup]))) .collect(); - let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0, 1, 2, 3, 4])), vec![]))]); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"arab"), Script::new(Some(LangSys::new(vec![0, 1, 2, 3, 4, 5])), vec![]))]); builder.add_table(&Gsub::new(scripts, FeatureList::new(records), lookups)).ok()?; for r in font.table_directory.table_records() { let tag = Tag::new(&r.tag().to_be_bytes()); diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index 2ac8da9586..7d11146c50 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -505,7 +505,9 @@ fn opentype_attrs(d: &Dialog, original: &Map, original_fields: &M tags.retain(|t| !sets.contains(&bare(t))); let listed = d.fields.get("ot_sets").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str); tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(|t| { - let kept = before.iter().rev().find(|b| bare(b) == bare(t)).filter(|b| !t.starts_with('-') && tag_on(b)); + let kept = before.iter().rev().find(|b| bare(b) == bare(t)).filter(|b| { + !t.starts_with('-') && vectorcraft_text::OtFeatures::known_tag(b) && tag_on(b) + }); kept.map_or_else(|| t.to_string(), Clone::clone) })); } @@ -1650,6 +1652,11 @@ mod tests { d.fields.insert("ot_sets".into(), json!(["ss01", "ss02"])); d.fields.insert("ot_salt".into(), json!(true)); assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["salt", "ss01", "ss02=2"])); + for last in ["ss01=0", "-ss01", "ss01=65536"] { + let mut d = dialog_for(json!({"features": ["ss01=2", last]})); + d.fields.insert("ot_sets".into(), json!(["ss01", "ss02"])); + assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["ss01", "ss02"]), "{last}: only a valid last on-value is kept"); + } } #[test] diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 66954b5d1b..5922d4be4b 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -6,8 +6,9 @@ //! stands in its word), and a double-click applies the OpenType feature that draws one to that //! character alone, so the text stays real text that joins its neighbors. A double-click on a //! glyph no character maps in Entire Font does the same when the selected character's font gives -//! that glyph as one of its alternates in its word; any other glyph is drawn dimmed, and its tooltip -//! says why it cannot be applied to the selection. +//! that glyph as one of its alternates in its word. A glyph no feature reaches whose name does +//! not belong to another letter is applied as a glyph choice, which may not join. Other glyphs +//! are drawn dimmed, and their tooltips say why they cannot be applied to the selection. use std::collections::HashMap; @@ -1188,6 +1189,41 @@ mod tests { assert!(text.runs.iter().all(|r| r.style.glyph_alternate.is_none()), "the choice is gone"); } + /// A positional alternate without a supported feature still goes through the engine's glyph + /// choice validation, even though the isolated character has different alternates. + #[test] + fn a_positional_variant_no_feature_draws_replaces_the_glyph() { + use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font, source_sans_gid}; + FontDb::global().add_font(joining_alternates_font().expect("the test font builds")); + let face = FontDb::global().face(JOINING_ALTERNATES_FAMILY, "Regular").expect("the test face"); + let gid = u32::from(source_sans_gid('d').expect("a glyph")); + assert!(face.alternates('\u{646}').is_empty(), "the isolated noon has no alternates"); + assert!(!face.is_unlisted_alternate('\u{646}', gid), "cv01 reaches the glyph"); + for view in ["Alternates for Current Selection", "Entire Font"] { + let (mut app, id) = editing("\u{628}\u{646}\u{62A}"); + app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap(); + let plain = glyph_ids(&app, &id); + select(&mut app, 2, 4); + let ctx = egui::Context::default(); + choose(&ctx, &mut app, view); + let at = if view == "Entire Font" { + entire_font_cell(&ctx, &mut app, &face, gid).0 + } else { + let cells = squares(&shapes(&ctx, &mut app, vec![])); + assert_eq!(cells.len(), 2, "the medial noon and its cv01 alternate"); + cells[1].center() + }; + let undo = app.session.active().unwrap().history.undo.len(); + double_click_in(&ctx, &mut app, at, if view == "Entire Font" { TALL } else { vec2(280.0, 420.0) }); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "{view}: {}", app.ui.status); + assert_eq!(glyph_ids(&app, &id)[1], gid, "{view}"); + let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), "\u{628}\u{646}\u{62A}", "the characters stay unchanged"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), plain, "{view}: undo restores the word"); + } + } + /// Show › Entire Font also lists the glyphs no character maps. #[test] fn entire_font_lists_the_unencoded_glyphs() { diff --git a/docs/gaps.md b/docs/gaps.md index 07b11c609f..ec13186293 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -839,6 +839,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 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. | | 2026-11-07 | minor | G4: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone (`salt=2`; the style commands take `tag=N`; runs that differ only in their features shape as one piece of text), so an Arabic word stays joined, and Show › Entire Font lists the unencoded glyphs (#5514). Estimate unchanged. | | 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 500fa15f2e..6419d4ac3e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1074,8 +1074,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`. - The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes: the grid lists the alternate glyphs of the character selected with the Type tool, and a double-click draws that character with the glyph as one undo step (`text.glyphAlternates`, `text.setGlyphAlternate`). The alternates come from the font's `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. The text keeps the character, native files keep the choice with the face it was made in, and type set in another font or version draws the default glyph. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. - The Glyphs panel's Alternates for Current Selection (#5514) applies the OpenType feature that draws the glyph to the selected character alone, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Alternates For Current Selection in the Show menu; double-click the glyph to replace the selected character). One deliberate difference from a literal reading: the text keeps the Unicode character and gains a character-style feature with a value (`salt=2`, `ss03`; `text.setRangeStyle {featureChanges}`) instead of taking an unencoded glyph in its place. The grid lists the alternates of the glyph the shaper makes for the character in its word (a medial letter shows its medial variants; the glyph it has comes first and is highlighted); a variant no feature draws there stays enabled as a replacement glyph (`text.setGlyphAlternate`), and its tooltip says it may not join its neighbors; the default glyph takes the character's alternate features off again. Runs that differ only in their OpenType features are shaped as one piece of text, each run's features over its own characters, so the letters of an Arabic word keep their joining forms. Show › Entire Font also lists the glyphs no character maps, with the font's name for them (or `gid N`) and the feature that gives them. These rules are VectorCraft's own. Tests: `tests::glyph_alternates` in the text crate (a fixture font; Kufi Murabba PIIIX when it is installed), `panels::glyphs::tests`, `tests::textedit::feature_values_are_set_replaced_and_saved`. + The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes. The grid lists the selected character's alternate glyphs from `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. A double-click applies the feature that draws the variant, or a glyph choice if no supported feature draws it, as detailed below. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Native files keep a glyph choice with the face it was made in; another font or version draws the default glyph. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`. + The Glyphs panel's Alternates for Current Selection (#5514) applies the OpenType feature that draws the glyph to the selected character alone, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Alternates For Current Selection in the Show menu; double-click the glyph to replace the selected character). One deliberate difference from a literal reading: the text keeps the Unicode character and gains a character-style feature with a value (`salt=2`, `ss03`; `text.setRangeStyle {featureChanges}`) instead of taking an unencoded glyph in its place. The grid lists the alternates of the glyph the shaper makes for the character in its word (a medial letter shows its medial variants; the default glyph comes first and the current glyph is highlighted); a variant no supported feature draws there stays enabled as a replacement glyph (`text.setGlyphAlternate`, including positional alternates), and its tooltip says it may not join its neighbors; the default glyph takes the character's alternate features off again. Runs that differ only in their OpenType features are shaped as one piece of text, each run's features over its own characters, so the letters of an Arabic word keep their joining forms. Show › Entire Font also lists the glyphs no character maps, with the font's name for them (or `gid N`) and the feature that gives them. It applies the selected letter's contextual alternates the same way, and applies a glyph no feature reaches whose name does not belong to another letter as a glyph choice; other glyphs are dimmed with the reason. An Arabic presentation form picked there inserts its base letter. These rules are VectorCraft's own. Tests: `tests::glyph_alternates` in the text crate (a fixture font; Kufi Murabba PIIIX when it is installed), `panels::glyphs::tests` (including positional glyph choices, unlisted glyph name ownership, apply and Undo), `tests::textedit::feature_values_are_set_replaced_and_saved`. The OpenType panel's Stylistic Sets menu offers `ss01`–`ss20` sets present in the selected text's font, using the font's custom labels when available and numbered labels otherwise. Sets can be combined and deselected individually. With mixed fonts, the menu shows the first selected character's font's sets and applies the feature tag to all selected text; a font without that set draws unchanged. The mixed-font rule is a VectorCraft design choice. Behavior reference: [Add stylistic sets to selected text](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html) and the [OpenType specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt). `text.setStyle {stroke, strokeWidth}` sets the character stroke's color and weight of the selected type or of a range of its characters (the weight in document points, as `stroke.set` sets it), in the same undo step as the command's other changes. Tests: `tests::textedit::set_style_sets_the_character_stroke`. - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. @@ -1892,6 +1892,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | Glyphs review (#6084): positional alternates with no supported feature are accepted as glyph choices in both panel views; Entire Font's unlisted-glyph and name-ownership behavior is retained. OpenType style options keep only a valid last on-value when enabling a set. Contradictory old glyph-choice evidence corrected; scores and estimates unchanged. | | 2026-11-07 | minor | Type core: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to the selected letter alone, so an Arabic word stays joined (`salt=2`; the style commands take `tag=N`), and Show › Entire Font lists the glyphs no character maps (#5514; [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html)); evidence added; scores unchanged. | | 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type keeps its paragraphs' tab stops, and 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); evidence added; scores unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index 4ce5569822..4647b4901a 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Glyphs review: a positional variant no supported feature draws is accepted as a glyph choice; invalid or off stylistic-set values are not preserved when a set is enabled; the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -30,7 +30,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Copy or Cut selected characters, then paste as separate text art | done | A Type-range copy keeps the selected characters' font family, size and color when pasted on the canvas after leaving text editing, in the same document or another. On desktop the clipboard offers plain text to text consumers and rich art for canvas Paste; Paste Without Formatting while editing uses the destination text's style. `tests::clipboard::type_range_copy_stays_styled_when_pasted_as_art` (#5636) | | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | -| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, a double-click applies the OpenType feature that draws one (`salt=N`, `ssNN`) to that character alone; a variant no feature reaches is drawn through `text.setGlyphAlternate`, which may not join; Show › Entire Font applies the selected letter's alternates the same way and dims the others with the reason; the glyph-cell pop-up of alternates is not there yet | +| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table. A double-click applies the OpenType feature that draws a variant to that character alone, or uses a glyph choice when no supported feature draws it; see the Glyphs panel row below for Entire Font and the joining limits. The glyph-cell pop-up of alternates is not there yet | | Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, applies one no feature reaches (`A.salt` in a font with no `salt`) to the selected character as a glyph choice (it may not join), and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the OpenType feature that draws the variant (`salt=N` or `ssNN`) to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature reaches falls back to a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. | | Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | @@ -82,6 +82,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | Glyphs review (#6084): the engine accepts a positional alternate as a glyph choice when no supported feature draws it, not only an isolated character's alternates; both panel views exercise apply and Undo. OpenType style options preserve only a valid last on-value when enabling a stylistic set. Scores and estimates unchanged. | | 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, applies the ones that are the selected letter's alternates, or that no feature reaches and the font's names do not give to another letter, and dims, with the reason, those that are not. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. | | 2026-11-07 | minor | Tabs: in right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none, when the tab ends the line) against the right edge; the line was aligned as a whole, which moved its start off the left edge. Illustrator's own layout in a user's `.ai` (used locally, not committed) sets its price lists so. Centred and justified lines keep their alignment as a whole. Scores and estimates unchanged. | | 2026-11-07 | minor | Soft return: in Justify Left, Center or Right, the line a soft return ends is justified as a wrapped line is; only the paragraph's last line keeps its alignment. Illustrator's own layout in a user's `.ai` (used locally, not committed) justifies those lines. Scores and estimates unchanged. | From 1ba9025583c10a2cf20c03543c284a10e0504464 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:35:08 +0430 Subject: [PATCH 08/79] Glyphs: warn for Entire Font glyph choices and fit regression previews --- crates/ui-egui/src/panels/glyphs.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 5922d4be4b..5a05c9d736 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -304,6 +304,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let resp = resp.on_hover_ui(|ui| { let tip = match (c, &pick) { (Some(c), _) => encoded_tip(&face, c, gid), + (None, Some(Some(Pick::Glyph(_)))) => format!( + "{}\n{}\n{}", + unencoded_tip(&face, gid), + tl!("Double-click to replace"), + tl!("No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors.") + ), (None, Some(Some(_))) => format!("{}\n{}", unencoded_tip(&face, gid), tl!("Double-click to replace")), (None, Some(None)) => { let why = fit.as_ref().map(|f| f.why(&face, gid)).unwrap_or_default(); @@ -1202,6 +1208,7 @@ mod tests { for view in ["Alternates for Current Selection", "Entire Font"] { let (mut app, id) = editing("\u{628}\u{646}\u{62A}"); app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap(); + set_cell_index(&mut app, 0); let plain = glyph_ids(&app, &id); select(&mut app, 2, 4); let ctx = egui::Context::default(); From 4f295e5fee8f98423720cf9b7cf370045c09d8ae Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Thu, 12 Nov 2026 01:06:28 +0530 Subject: [PATCH 09/79] UI: keep numeric field caret on short pointer drags (#1326) A pointer movement under the click tolerance was still treated as a tap, so the focus click selected the whole numeric value. Any movement from the press now keeps the caret; only a tap without movement selects all. Regression test: a_sub_threshold_movement_released_as_a_click_keeps_its_caret. --- crates/ui-egui/src/tests/numfields.rs | 17 +++++++++++++++++ crates/ui-egui/src/widgets/text.rs | 8 ++++---- docs/gaps.md | 2 +- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index 45801c22ec..d7ceed77b9 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -1361,6 +1361,23 @@ fn a_short_drag_released_as_a_click_keeps_its_caret() { assert_eq!(f.frame(vec![enter()], &draw), Some(1273456.0), "typing lands where the drag left the caret"); } +/// #1326: the smallest pointer movement egui still reports as a click (under the 3-point drag +/// threshold) must keep the caret too; only a press released where it went down selects all. +#[test] +fn a_sub_threshold_movement_released_as_a_click_keeps_its_caret() { + let mut f = Field::new(); + let draw = |ui: &mut egui::Ui| numeric_fixture(ui, 0); + f.frame(vec![], &draw); + let at = f.text_point(field_text(0), 2, 7.0); + f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw); + let pressed = f.selection(); + let nudge = egui::vec2(1.5, 0.0); + f.frame(vec![Event::PointerMoved(at + nudge)], &draw); + f.frame(vec![pointer_button(at + nudge, false)], &draw); + assert_ne!(f.selection(), Some((0, field_text(0).chars().count())), "a 1.5 px movement is a drag, not a tap (#1326)"); + assert_eq!(f.selection(), pressed, "the movement keeps the caret where the press landed"); +} + /// A tap (no movement) on a fresh numeric field still selects all on release. #[test] fn a_tap_without_movement_still_selects_all_on_release() { diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index 3551baebd2..5ede108998 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -171,13 +171,13 @@ pub(super) fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { }); } let clicked_focus = released && ui.data_mut(|d| d.remove_temp::(pending)).unwrap_or(false) && resp.clicked_by(egui::PointerButton::Primary); - // A press released a few points away is a short drag, not a tap. egui still reports a - // click, and a release inside one character leaves no text selection, so select-all on - // release would replace the drag the user just made (#1326). + // Any pointer movement from the press is a drag, not a tap. egui still reports a click for + // a movement under its click tolerance, and a release inside one character leaves no text + // selection, so select-all on release would replace the drag the user just made (#1326). let dragged = clicked_focus && ui .data_mut(|d| d.remove_temp::(press_pos)) - .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 3.0)); + .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 0.0)); if resp.lost_focus() { ui.data_mut(|d| { d.remove_temp::(pending); diff --git a/docs/gaps.md b/docs/gaps.md index 07308c801a..be3ffe3f41 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -320,7 +320,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** Divide splits filled paths along open cutting lines and curves (#1307), preserving the frontmost filled appearance, implicit filled closure, holes, unrelated objects and one Undo/Redo; the existing bounded-map fallback remains. -- **Done:** Numeric fields keep a text drag's start when the pointer gains focus and movement exceeds the click threshold (#1326, in part). A completed focus click selects the whole value; keyboard focus, double-click and numeric stepping remain regression-tested. Short movements still classified as clicks can select all; that case remains open. +- **Done:** Numeric fields keep a text drag's start when the pointer gains focus, including short movements that egui still classifies as clicks (#1326). Any pointer movement from the press keeps the caret; only a tap without movement selects the whole value. Keyboard focus, double-click and numeric stepping remain regression-tested (`tests/numfields.rs`, `a_short_drag_released_as_a_click_keeps_its_caret` and `a_sub_threshold_movement_released_as_a_click_keeps_its_caret`). - **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. - **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. From f0e50437c22649353e53f80ac6f572d7df686420 Mon Sep 17 00:00:00 2001 From: Ahmed Gomaa Date: Wed, 11 Nov 2026 23:16:03 +0200 Subject: [PATCH 10/79] Save on a read-only location offers Save As A plain Save that the location refuses (read-only filesystem or permission denied) now opens Save As with the name filled in, instead of ending in an error dialog. The refusal travels as a typed engine error (READ_ONLY_LOCATION), so the control protocol and MCP still return an error and never open a panel. Recovery failures, explicit destinations, a busy picker and Close/Quit keep their existing flow. Closes #6146 --- apps/vectorcraft/src/host_services.rs | 2 + crates/engine/src/cmd/fileio.rs | 11 +- crates/engine/src/cmd/fileio/access.rs | 25 ++ crates/engine/src/cmd/fileio/save.rs | 9 +- .../engine/src/cmd/fileio/tests_readonly.rs | 52 ++++ crates/engine/src/error.rs | 7 + crates/engine/src/lib.rs | 2 +- crates/format/src/atomic.rs | 48 +++- crates/format/src/lib.rs | 2 +- crates/mcp/src/tests/fileio.rs | 23 ++ crates/ui-egui/src/io.rs | 5 + crates/ui-egui/src/io/read_only_save.rs | 64 +++++ crates/ui-egui/src/io/save.rs | 96 ++++++- crates/ui-egui/src/io/tests_read_only_save.rs | 266 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 +- crates/ui-egui/src/menus/run.rs | 2 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/picks.rs | 9 + crates/ui-egui/src/services.rs | 11 + docs/control-protocol.md | 25 +- docs/file-format-parity.md | 5 + docs/gaps.md | 5 +- docs/mcp.md | 25 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 9 +- docs/ui-parity.md | 4 + 26 files changed, 684 insertions(+), 33 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_readonly.rs create mode 100644 crates/ui-egui/src/io/read_only_save.rs create mode 100644 crates/ui-egui/src/io/tests_read_only_save.rs diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 187c42cde7..bd7c8aec80 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -358,6 +358,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), // Background Save and Export write from a worker thread. write_shared: Some(std::sync::Arc::new(write_file)), + save_write: Some(std::sync::Arc::new(fileio::write_save_file)), + save_stream: Some(std::sync::Arc::new(fileio::write_save_file_with)), // Native saves stream into the file as they are encoded. stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { fileio::write_file_with(p, fill).map_err(|e| e.to_string()) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 04a443ed4c..2b7969a434 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -89,9 +89,9 @@ pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option, - remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, - templates_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, + reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, + template_filters, templates_dialog_folder, templates_folder, }; pub(crate) use screens::{ PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS, @@ -120,7 +120,10 @@ mod formats; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::list_dir; -pub use access::write_file_with; +pub use access::{write_file_with, write_save_file, write_save_file_with}; + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests_readonly; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::{Placed, copy_new, file_metadata}; pub(crate) use access::{absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, write_file, write_new_file}; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 4dce2023bb..0e69147a32 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -89,6 +89,31 @@ pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } +/// The save commands' writer: preserves a filesystem location refusal as a typed error. Other +/// writers keep their existing errors; an automation-root refusal is never reclassified. +pub fn write_save_file(path: &str, bytes: &[u8]) -> Result<()> { + write_save_file_with(path, &mut |w| w.write_all(bytes)) +} + +/// [`write_save_file`] for a streamed save. +#[cfg(not(target_arch = "wasm32"))] +pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + vectorcraft_doc::cancel::check()?; + may_write(path)?; + vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| save_write_error(path, e)) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn save_write_error(path: &str, error: std::io::Error) -> EngineError { + let reason = format!("{path}: {error}"); + if vectorcraft_format::read_only_location(&error) { EngineError::ReadOnlyLocation(reason) } else { EngineError::Other(reason) } +} + +#[cfg(target_arch = "wasm32")] +pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + write_file_with(path, fill) +} + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn create_dir(path: &str) -> Result<()> { may_write(path)?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 9f4e5b0572..791c25a9a2 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -157,6 +157,11 @@ fn documents_folder() -> Option { Some(home.join("Documents")) } +/// A Save As after a refused Save starts in Documents, else home, never the refused folder. +pub fn documents_dialog_folder() -> Option { + documents_folder().filter(|p| p.is_dir()).map(|p| p.to_string_lossy().into_owned()).or_else(home_folder) +} + /// `XDG_DOCUMENTS_DIR` in a `user-dirs.dirs` file's `text`, `$HOME` being `home`. None when it isn't /// set, is relative, or is the home folder itself (the spec's way of turning it off). fn xdg_documents(text: &str, home: &std::path::Path) -> Option { @@ -1032,7 +1037,7 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result let plan = save_plan(s, mode, p)?; let job = save_job(s, plan)?; let uid = s.doc()?.uid; - let out = job.write_streamed(&mut super::write_file_with)?; + let out = job.write_streamed(&mut super::write_save_file_with)?; job.complete(s, uid); Ok(out) } @@ -1104,7 +1109,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/tests_readonly.rs b/crates/engine/src/cmd/fileio/tests_readonly.rs new file mode 100644 index 0000000000..badf82a8cf --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_readonly.rs @@ -0,0 +1,52 @@ +//! Save errors retain their type without changing the other filesystem writers (#6146). + +use std::io::{self, ErrorKind}; + +use serde_json::json; + +use super::{SaveMode, access, save_plan, save_with, write_file_with, write_save_file_with}; +use crate::{EngineError, READ_ONLY_LOCATION, Session}; + +#[test] +fn readonly_error_is_typed_and_other_writers_keep_their_error() { + let dir = vectorcraft_testkit::temp_dir("save-refusal"); + let path = dir.join("Poster.vectorcraft").to_string_lossy().into_owned(); + for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem, ErrorKind::StorageFull, ErrorKind::NotFound, ErrorKind::Other] { + let typed = write_save_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err(); + let ordinary = write_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err(); + let reason = format!("{path}: injected"); + assert!(matches!(&ordinary, EngineError::Other(_))); + assert_eq!(ordinary.to_string(), reason); + if matches!(kind, ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) { + assert!(matches!(&typed, EngineError::ReadOnlyLocation(_))); + assert_eq!(typed.to_string(), format!("{READ_ONLY_LOCATION}: {reason}")); + } else { + assert!(matches!(&typed, EngineError::Other(_))); + assert_eq!(typed.to_string(), reason); + } + assert!(!std::path::Path::new(&path).exists()); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn readonly_failed_save_keeps_document_and_options() { + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 40, "height": 40})).unwrap(); + session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + let st = session.doc_mut().unwrap(); + st.path = Some("Poster.svg".into()); + st.format = "svg"; + st.save_options = json!({"decimals": 3}).as_object().unwrap().clone(); + let before = serde_json::to_value(&*st.doc).unwrap(); + let options = st.save_options.clone(); + let plan = save_plan(&session, SaveMode::Save, &json!({"modified": null})).unwrap(); + let e = save_with(&mut session, plan, |path, _| Err(access::save_write_error(path, io::Error::from(ErrorKind::PermissionDenied)))).unwrap_err(); + assert!(matches!(e, EngineError::ReadOnlyLocation(_))); + let st = session.doc().unwrap(); + assert_eq!(st.path.as_deref(), Some("Poster.svg")); + assert_eq!(st.format, "svg"); + assert_eq!(st.save_options, options); + assert!(st.is_dirty()); + assert_eq!(serde_json::to_value(&*st.doc).unwrap(), before); +} diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index d32fda613d..6ab1608bd7 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -3,6 +3,9 @@ use super::{NodeId, cancel}; /// The text of [`EngineError::Busy`], which ends every error a frontend reports for it. pub const SCRIPT_BUSY: &str = "a script is running: stop it or wait for it to finish"; +/// Stable prefix for a save refused by the filesystem: choose another destination. +pub const READ_ONLY_LOCATION: &str = "READ_ONLY_LOCATION"; + #[derive(Debug, thiserror::Error)] pub enum EngineError { #[error("unknown command `{0}`")] @@ -20,6 +23,10 @@ pub enum EngineError { NoNode(NodeId), #[error("{0}")] Other(String), + /// The save's terminal filesystem error refuses writes here. The reason keeps the path and + /// system message. This never means an automation-root refusal or a recovery-copy failure. + #[error("{code}: {0}", code = READ_ONLY_LOCATION)] + ReadOnlyLocation(String), /// The caller asked for the command to stop ([`cancel`]). It changed nothing. #[error("cancelled; nothing was changed")] Cancelled, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 091348a6bd..6bc570623d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -370,7 +370,7 @@ pub(crate) use command_sets::SELECTS_GIVEN_IDS; use command_sets::given_ids; pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit}; pub use doc_state::{DocState, OpenRows, RecalledSelection}; -pub use error::{EngineError, Result, SCRIPT_BUSY}; +pub use error::{EngineError, READ_ONLY_LOCATION, Result, SCRIPT_BUSY}; #[cfg(test)] use history::history_budget_for; pub use history::{History, HistoryEntry, NamedStep, default_history_budget}; diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 863c6fdd34..8e7149cad2 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -18,6 +18,25 @@ use std::io::{self, Write as _}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +/// Whether the returned write error refuses this location. Recovery errors must still be shown: +/// their message names copies the user may need, even when their kind is PermissionDenied. +pub fn read_only_location(error: &io::Error) -> bool { + matches!(error.kind(), io::ErrorKind::ReadOnlyFilesystem | io::ErrorKind::PermissionDenied) + && !error.get_ref().is_some_and(|e| e.is::()) +} + +/// Marks the existing recovery message without changing its text or the returned error kind. +#[derive(Debug)] +struct RecoveryError(String); + +impl std::fmt::Display for RecoveryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for RecoveryError {} + /// Write `bytes` to `path` atomically (see the module docs). pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { write_atomic_with(path, |f| f.write_all(bytes)) @@ -102,7 +121,7 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> { let _ = std::fs::remove_file(old); let why = format!("{refused}, and the file can't be copied aside before it is overwritten ({e}); the new content is kept in {}", tmp.display()); - return Err(io::Error::new(refused.kind(), why)); + return Err(io::Error::new(refused.kind(), RecoveryError(why))); } let copied = to.set_len(0).and_then(|()| io::copy(&mut from, &mut to)).and_then(|_| sync_file(&to)); drop((from, to)); @@ -115,10 +134,11 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> { } Ok(()) } - (Err(e), Some(old)) => { - Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display()))) - } - (Err(e), None) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))), + (Err(e), Some(old)) => Err(io::Error::new( + e.kind(), + RecoveryError(format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display())), + )), + (Err(e), None) => Err(io::Error::new(e.kind(), RecoveryError(format!("{e} (the new content is kept in {})", tmp.display())))), } } @@ -318,6 +338,24 @@ fn keep_permissions(_: &Path, _: &Path) -> io::Result<()> { mod tests { use super::*; + #[test] + fn readonly_classification_uses_the_terminal_kind_and_excludes_recovery() { + for (kind, expected) in [ + (io::ErrorKind::PermissionDenied, true), + (io::ErrorKind::ReadOnlyFilesystem, true), + (io::ErrorKind::StorageFull, false), + (io::ErrorKind::QuotaExceeded, false), + (io::ErrorKind::NotFound, false), + (io::ErrorKind::Other, false), + ] { + assert_eq!(read_only_location(&io::Error::new(kind, "injected")), expected, "{kind:?}"); + let recovery = io::Error::new(kind, RecoveryError("the new content is kept in a.tmp".into())); + assert!(!read_only_location(&recovery), "recovery: {kind:?}"); + assert_eq!(recovery.kind(), kind); + assert_eq!(recovery.to_string(), "the new content is kept in a.tmp"); + } + } + fn dir(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("vc-atomic-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 38545b8292..ed1777b6de 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -55,7 +55,7 @@ use serde::{Deserializer, Serialize}; use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] -pub use atomic::{sync_file, write_atomic, write_atomic_with, write_new_with}; +pub use atomic::{read_only_location, sync_file, write_atomic, write_atomic_with, write_new_with}; pub use craft_vectorcraft_format::base64::{decode as base64_decode, decoded_len as base64_decoded_len, encode as base64_encode}; pub use craft_vectorcraft_format::{ BLOB_MAGIC, COMPRESSED_SINCE, EXTENSION, ICC_MIME, INLINE_MAX, LEGACY_EXTENSION, MIN_VERSION, PREVIEW_MAX, VERSION, is_compressed, json_part, diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs index 00ff3a2d77..d29161a68b 100644 --- a/crates/mcp/src/tests/fileio.rs +++ b/crates/mcp/src/tests/fileio.rs @@ -357,6 +357,29 @@ fn save_file_waits_for_the_file_in_the_app() { } } +#[test] +fn readonly_save_file_keeps_the_stable_error_prefix() { + struct Refused(bool); + impl Backend for Refused { + fn call(&mut self, method: &str, params: Value) -> Result { + assert_eq!(method, "app.save"); + assert_eq!(params.get("wait").and_then(Value::as_bool), self.0.then_some(true)); + Err(vectorcraft_engine::EngineError::ReadOnlyLocation("Poster.svg: injected refusal".into()).to_string()) + } + fn has_ui(&self) -> bool { + self.0 + } + fn describe(&self) -> String { + "injected save refusal".into() + } + } + for desktop in [false, true] { + let r = call_tool(&mut Refused(desktop), "save_file", &json!({})); + assert!(r.is_error); + assert_eq!(r.content[0]["text"], "save_file: READ_ONLY_LOCATION: Poster.svg: injected refusal"); + } +} + fn pdf_pages(pdf: &[u8]) -> usize { let mut h = Headless::new(); h.call("engine.execute", json!({"command": "document.open", "params": {"name": "p.pdf", "dataBase64": vectorcraft_format::base64_encode(pdf)}})) diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index cc6f5d2262..a3a41c922a 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -15,8 +15,12 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs}; mod export; mod open; +mod read_only_save; mod save; +#[cfg(test)] +mod tests_read_only_save; + pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, export_pdf, hide_exports, reveal, save_command_output}; pub(crate) use export::{ export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named, @@ -29,6 +33,7 @@ pub use open::{ }; #[cfg(test)] pub(crate) use open::{open_file, opened_with_notes}; +pub(crate) use save::save_current; pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf}; pub(crate) use save::{export_path, is_web, report_saved, target_path, write_named, write_reporting, write_to}; #[cfg(test)] diff --git a/crates/ui-egui/src/io/read_only_save.rs b/crates/ui-egui/src/io/read_only_save.rs new file mode 100644 index 0000000000..f49603eba2 --- /dev/null +++ b/crates/ui-egui/src/io/read_only_save.rs @@ -0,0 +1,64 @@ +//! The continuation of ordinary File > Save when its own location refuses writes (#6146). +//! It reuses Save As, including extension and overwrite checks, under its own picker entry. + +use std::rc::Rc; + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio::{self, SaveMode, SavePlan}; + +use crate::picks::{self, Entry}; +use crate::{FilePick, VectorcraftApp}; + +pub(super) struct Retry { + path: Option, + pick: FilePick, + params: Value, +} + +impl Retry { + pub(super) fn new(plan: &SavePlan, folder: Option) -> Self { + let name = plan.path.as_deref().map(fileio::file_name).unwrap_or_else(|| plan.name.clone()); + let mut params = json!({ "format": plan.format.id, "options": plan.options }); + if let Some(modified) = plan.modified { + params["modified"] = json!(modified); + } + Self { path: plan.path.clone(), pick: FilePick { name, folder, filters: vec![] }, params } + } + + /// None when this continuation is stale or another picker prevents it. The existing failed-save + /// dialog then stays in charge; there is no queue of deferred Save As requests. + pub(super) fn instead(self, app: &mut VectorcraftApp, uid: u64) -> Option> { + if app.automation_is_acting() || app.services.pick_save.is_none() || app.picks.is_waiting() || super::save::is_web(app) { + return None; + } + let index = app.session.documents().iter().position(|st| st.uid == uid && st.path == self.path)?; + if app.session.active_index() != Some(index) && app.run("document.activate", json!({ "index": index })).is_err() { + return None; + } + let name = &self.pick.name; + let status = format!("Cannot write to the location of “{name}”; choose where to save it"); + app.status(status.clone()); + let save = Rc::new(move |app: &mut VectorcraftApp| { + // An agent can retarget this document while the sheet is open. Its answer must not + // replace that later choice. The picker itself already checks the document's uid. + if !app.session.active().is_some_and(|st| st.uid == uid && st.path == self.path) { + return Err("cancelled".into()); + } + super::save::save_suggesting(app, SaveMode::SaveAs, &self.params, false, Some(&self.pick)) + }); + let again = save.clone(); + let entry = Entry::Call(Rc::new(move |app| { + if let Err(e) = again(app) { + app.status(e); + } + })); + let r = picks::as_own_entry(app, entry, |app| save(app)); + Some(match r { + Err(e) if e == "cancelled" && app.picks.is_waiting() => { + app.status(status); + Ok(json!({ "pending": "fileDialog" })) + } + r => r, + }) + } +} diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index cfbdaaadba..5efe776d64 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -241,6 +241,31 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result Save alone offers another destination when its own location refuses writes. +/// Save Changes during Close/Quit continues to use [`save`]. +pub(crate) fn save_current(app: &mut VectorcraftApp, p: &Value) -> Result { + save_current_in(app, p, fileio::documents_dialog_folder()) +} + +/// The same entry with a resolved Documents folder (also permits deterministic host-free tests). +pub(super) fn save_current_in(app: &mut VectorcraftApp, p: &Value, folder: Option) -> Result { + remember_view(app); + let first = plan(app, SaveMode::Save, &acknowledged(p))?; + let own = !app.automation_is_acting() + && !is_web(app) + && p.get("path").is_none_or(Value::is_null) + && first.path.is_some() + && app.session.active().is_some_and(|st| !st.converted && !st.recovered && st.path == first.path && st.format == first.format.id); + if !own { + return save(app, SaveMode::Save, p, true); + } + if let Some(r) = first.path.as_deref().and_then(|path| ask_before_losing(app, path, "file.save", p)) { + return Ok(r); + } + let retry = super::read_only_save::Retry::new(&first, folder); + write_plan(app, first, Some(retry)) +} + /// File → Save, Save As…, Save a Copy…, Save as Template… and the options dialogs' OK. `p` is the /// engine command's `{path?, format?, options?, svg?}`. /// @@ -253,6 +278,17 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result, /// path?}` while a dialog is open. pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result { + save_suggesting(app, mode, p, ask_options, None) +} + +/// Reuse Save As with a captured name and folder, without changing the document before writing. +pub(super) fn save_suggesting( + app: &mut VectorcraftApp, + mode: SaveMode, + p: &Value, + ask_options: bool, + suggestion: Option<&FilePick>, +) -> Result { remember_view(app); // Writing over the file the document was read from is asked about here, not refused. let first = plan(app, mode, &acknowledged(p))?; @@ -264,7 +300,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { return ask_format_options(app, mode, first.format, &path); } - return write_plan(app, first); + return write_plan(app, first, None); } if is_web(app) && ask_options { return Ok(open_options(app, mode.command(), first.format, &first.name, true)); @@ -273,8 +309,12 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo SaveMode::Template => fileio::template_filters(first.format.id), _ => fileio::save_filters(first.format.id), }); - let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters }; - let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &first.name, file_type, first.format, mode))?; + let pick = FilePick { + name: suggestion.map_or_else(|| first.name.clone(), |s| s.name.clone()), + folder: suggestion.map_or_else(|| first.folder.clone(), |s| s.folder.clone()), + filters, + }; + let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &pick.name, file_type, first.format, mode))?; // The picked file type wins over a `format` param. let mut q = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = q.as_object_mut() { @@ -291,7 +331,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo crate::dialogs::opened_by_panel(app); return r; } - write_plan(app, chosen) + write_plan(app, chosen, None) } /// The UI command that runs a save in `mode`. @@ -418,7 +458,7 @@ pub(crate) fn saved_status(path: &str, r: &Value) -> String { /// ([`background`]): this returns `{path, background: true, status, format, warnings}` at once /// (`warnings`: what the format loses, then the save's notes on its params, such as an option the format /// doesn't read), and the document takes the file (and counts as saved, as of the snapshot) once it is written. -fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result { +fn write_plan(app: &mut VectorcraftApp, plan: SavePlan, retry: Option) -> Result { let uid = app.session.active().ok_or("no document")?.uid; // Saves of one document finish in order. if app.background.busy_with(uid) { @@ -444,19 +484,53 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result let enabled = app.session.prefs.background_save && target.is_some(); // The work writes the file; the copy makes it the document's own afterwards. let done = job.clone(); + // Capture the caller before background work leaves the request: it must never open a panel + // or error dialog later, when answering_control has been reset. + let by_control = app.automation_is_acting(); + let location_refused = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let refused_by_work = location_refused.clone(); + let own_path = target.clone(); + let save_write = app.services.save_write.clone().filter(|_| !is_web(app)); + let save_stream = app.services.save_stream.clone().filter(|_| job.streams() && !is_web(app)); // A native file streams into the file as it is encoded (desktop; not to a download). let stream = app.services.stream_shared.clone().filter(|_| job.streams() && !is_web(app)); let work = move |write: Writer| { - match stream { - Some(stream) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), - None => job.write(|p, b| write(p, b).map_err(EngineError::Other)), - } - .map_err(|e| e.to_string()) + let mut own_refused = false; + let mut observed = |p: &str, r: vectorcraft_engine::Result<()>| { + own_refused = own_path.as_deref() == Some(p) && matches!(&r, Err(EngineError::ReadOnlyLocation(_))); + r + }; + let r = match (save_stream, save_write, stream) { + (Some(stream), _, _) => job.write_streamed(&mut |p, fill| observed(p, stream(p, fill))), + (_, Some(write), _) => job.write(|p, b| observed(p, write(p, b))), + (_, _, Some(stream)) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), + _ => job.write(|p, b| write(p, b).map_err(EngineError::Other)), + }; + r.map_err(|e| { + if let EngineError::ReadOnlyLocation(reason) = &e { + refused_by_work.store(own_refused, std::sync::atomic::Ordering::Relaxed); + if !by_control { + return reason.clone(); + } + } + e.to_string() + }) }; let failed = target.clone().unwrap_or_default(); let then = move |app: &mut VectorcraftApp, r: Result| { + if r.is_err() + && location_refused.load(std::sync::atomic::Ordering::Relaxed) + && let Some(retry) = retry + && let Some(r) = retry.instead(app, uid) + { + return r; + } // Not written: the document keeps its file, format and modified state, and the user is told. - let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?; + let r = r.inspect_err(|e| { + if !by_control { + report_write_error(app, false, &failed, e); + } + })?; // The document may have closed meanwhile: the file is written all the same. done.complete(&mut app.session, uid); let path = r["path"].as_str().unwrap_or_default().to_string(); diff --git a/crates/ui-egui/src/io/tests_read_only_save.rs b/crates/ui-egui/src/io/tests_read_only_save.rs new file mode 100644 index 0000000000..8a01003a90 --- /dev/null +++ b/crates/ui-egui/src/io/tests_read_only_save.rs @@ -0,0 +1,266 @@ +//! Injected writers and picker answers: no native panel or filesystem permissions are needed. + +use std::io::ErrorKind; +use std::sync::{Arc, Mutex, mpsc}; + +use serde_json::{Value, json}; +use vectorcraft_engine::{EngineError, READ_ONLY_LOCATION, Session}; + +use super::{fileio, save}; +use crate::control::ControlRequest; +use crate::tests::picks::off_the_ui_thread; +use crate::{Services, VectorcraftApp, background, dialogs, picks}; + +const DOCUMENTS: &str = "/home/artist/Documents"; + +#[derive(Default)] +struct Files { + original: String, + attempts: Vec, + kind: Option, + fail_second: bool, +} + +fn write(files: &Mutex, path: &str) -> vectorcraft_engine::Result<()> { + let mut files = files.lock().unwrap(); + files.attempts.push(path.into()); + if path == files.original || files.fail_second { + let reason = format!("{path}: injected refusal"); + return Err(match files.kind { + Some(ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) => EngineError::ReadOnlyLocation(reason), + _ => EngineError::Other(reason), + }); + } + Ok(()) +} + +fn app(format: &str, kind: ErrorKind) -> (VectorcraftApp, Arc>) { + let files = Arc::new(Mutex::new(Files { original: format!("/locked/Poster.{format}"), kind: Some(kind), ..Default::default() })); + let typed = files.clone(); + let ordinary = files.clone(); + let services = Services { + save_write: Some(Arc::new(move |path, _| write(&typed, path))), + write: Some(Box::new(move |path, _| { + write(&ordinary, path).map_err(|e| match e { + EngineError::ReadOnlyLocation(reason) => reason, + e => e.to_string(), + }) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 40, "height": 40})).unwrap(); + app.run("shape.rectangle", json!({"x": 1, "y": 1, "width": 10, "height": 10})).unwrap(); + let st = app.session.doc_mut().unwrap(); + st.path = Some(files.lock().unwrap().original.clone()); + st.format = fileio::format(format).unwrap().id; + st.save_options = if format == "svg" { + json!({"decimals": 2, "preserveEditing": false}) + } else { + json!({"pdfCompatible": false, "compress": true, "preview": false}) + } + .as_object() + .unwrap() + .clone(); + (app, files) +} + +fn state(app: &VectorcraftApp) -> (Option, bool, &'static str, Value) { + let st = app.session.doc().unwrap(); + (st.path.clone(), st.is_dirty(), st.format, json!(st.save_options)) +} + +fn poll(app: &mut VectorcraftApp) { + picks::poll(app, &egui::Context::default()); +} + +fn normal_error(app: &VectorcraftApp) { + let d = app.ui.dialog.as_ref().expect("a persistent error dialog"); + assert_eq!(d.kind, dialogs::confirm::MESSAGE); + assert!(d.str("detail").contains("injected refusal")); + assert!(!d.str("detail").contains(READ_ONLY_LOCATION)); + assert!(app.ui.told.is_empty(), "no duplicate error dialog"); +} + +#[test] +fn readonly_fallback_prefills_save_as_and_cancel_keeps_the_failed_save_state() { + for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem] { + let (mut app, files) = app("svg", kind); + let before = state(&app); + let (shown, _) = off_the_ui_thread(&mut app.services); + let r = save::save_current_in(&mut app, &json!({"modified": null}), Some(DOCUMENTS.into())).unwrap(); + assert_eq!(r["pending"], "fileDialog"); + let requests = shown.requests(); + let [picks::PickRequest::Save(pick)] = requests.as_slice() else { panic!("expected one save panel: {requests:?}") }; + assert_eq!(pick.name, "Poster.svg"); + assert_eq!(pick.folder.as_deref(), Some(DOCUMENTS)); + assert_eq!(pick.filters.first(), crate::FilePick::filters(fileio::save_filters("svg")).first()); + assert!(app.ui.status.contains("choose where to save it")); + assert_eq!(state(&app), before); + assert!(app.ui.dialog.is_none()); + shown.answer(&[]); + poll(&mut app); + assert_eq!(state(&app), before); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg"]); + assert!(!app.picks.is_waiting()); + } +} + +#[test] +fn readonly_save_as_retargets_only_after_write_and_keeps_options() { + let (mut app, files) = app("svg", ErrorKind::PermissionDenied); + let before = state(&app); + let uid = app.session.doc().unwrap().uid; + let (shown, _) = off_the_ui_thread(&mut app.services); + assert_eq!(app.run("file.save", json!({})).unwrap()["pending"], "fileDialog"); + // Hold the second write after the picker answers: the chosen path is not yet the document's. + let (entered, writing) = mpsc::channel(); + let (release, released) = mpsc::channel(); + let released = Mutex::new(released); + let f = files.clone(); + app.services.save_write = Some(Arc::new(move |path, _| { + entered.send(()).unwrap(); + released.lock().unwrap().recv().unwrap(); + write(&f, path) + })); + app.services.write_shared = Some(Arc::new(|_, _| panic!("typed saves must use their own writer"))); + app.session.prefs.background_save = true; + shown.answer(&["/home/artist/Documents/Poster.svg"]); + poll(&mut app); + writing.recv().unwrap(); + assert_eq!(state(&app), before); + release.send(()).unwrap(); + background::wait_all(&mut app); + let st = app.session.document_mut(uid).unwrap(); + assert_eq!(st.path.as_deref(), Some("/home/artist/Documents/Poster.svg")); + assert!(!st.is_dirty()); + assert_eq!(st.format, before.2); + assert_eq!(json!(st.save_options), before.3); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg", "/home/artist/Documents/Poster.svg"]); + assert!(shown.requests().is_empty() && app.ui.dialog.is_none()); +} + +#[test] +fn readonly_second_failure_shows_one_dialog_without_reopening_the_picker() { + let (mut app, files) = app("svg", ErrorKind::PermissionDenied); + files.lock().unwrap().fail_second = true; + let before = state(&app); + let (shown, _) = off_the_ui_thread(&mut app.services); + app.run("file.save", json!({})).unwrap(); + shown.answer(&["/elsewhere/Poster.svg"]); + poll(&mut app); + normal_error(&app); + assert_eq!(state(&app), before); + assert!(shown.requests().is_empty() && !app.picks.is_waiting()); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg", "/elsewhere/Poster.svg"]); +} + +#[test] +fn readonly_other_errors_and_explicit_destinations_keep_the_dialog() { + for (command, params, kind) in [ + ("file.save", json!({}), ErrorKind::StorageFull), + ("file.save", json!({}), ErrorKind::NotFound), + ("file.save", json!({}), ErrorKind::Other), + ("file.save", json!({"path": "/locked/Poster.svg"}), ErrorKind::PermissionDenied), + ("file.saveAs", json!({"path": "/locked/Poster.svg", "options": {"decimals": 2}}), ErrorKind::PermissionDenied), + ("file.saveCopy", json!({"path": "/locked/Poster.svg", "options": {"decimals": 2}}), ErrorKind::PermissionDenied), + ("file.exportAs", json!({"path": "/locked/Poster.svg", "format": "svg"}), ErrorKind::PermissionDenied), + ] { + let (mut app, _) = app("svg", kind); + let before = state(&app); + // A fallback would return pending, so no inline/native dialog is necessary here. + app.services.pick_save = Some(Box::new(move |_| panic!("{command}: no fallback"))); + assert!(app.run(command, params).is_err(), "{command}"); + normal_error(&app); + assert_eq!(state(&app), before); + } +} + +#[test] +fn readonly_control_returns_the_prefix_and_never_opens_a_panel_even_after_completion() { + for wait in [true, false] { + let (mut app, _) = app("svg", ErrorKind::PermissionDenied); + app.services.pick_save = Some(Box::new(|_| panic!("automation never picks"))); + app.services.write_shared = Some(Arc::new(|_, _| panic!("typed writer required"))); + app.session.prefs.background_save = true; + let before = state(&app); + let (tx, rx) = mpsc::channel(); + app = app.with_control(rx); + let (req, reply) = ControlRequest::new("app.save", json!({"wait": wait})); + tx.send(req).unwrap(); + app.drain_control(&egui::Context::default()); + let reply = reply.try_recv().unwrap(); + if wait { + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap().starts_with("READ_ONLY_LOCATION: "), "{reply}"); + } else { + assert_eq!(reply["result"]["background"], true); + assert!(!app.ui.answering_control); + background::wait_all(&mut app); + assert!(app.ui.status.contains("READ_ONLY_LOCATION: ")); + } + assert_eq!(state(&app), before); + assert!(app.ui.dialog.is_none() && app.ui.told.is_empty() && !app.picks.is_waiting()); + } +} + +#[test] +fn readonly_background_completion_resumes_the_original_uid_without_repeating_save() { + for streamed in [false, true] { + let format = if streamed { "vectorcraft" } else { "svg" }; + let (mut app, files) = app(format, ErrorKind::ReadOnlyFilesystem); + let original = files.lock().unwrap().original.clone(); + let uid = app.session.doc().unwrap().uid; + let (shown, _) = off_the_ui_thread(&mut app.services); + if streamed { + let f = files.clone(); + app.services.save_stream = Some(Arc::new(move |path, fill| { + let mut bytes = Vec::new(); + fill(&mut bytes).unwrap(); + write(&f, path) + })); + app.services.save_write = Some(Arc::new(|_, _| panic!("native save must stream"))); + } + app.services.write_shared = Some(Arc::new(|_, _| panic!("typed writer required"))); + app.session.prefs.background_save = true; + assert_eq!(app.run("file.save", json!({})).unwrap()["background"], true); + app.run("file.new", json!({"width": 20, "height": 20})).unwrap(); + let other = app.session.doc().unwrap().uid; + background::wait_all(&mut app); + assert_eq!(app.session.doc().unwrap().uid, uid); + assert_eq!(shown.requests().len(), 1); + // Switch again while the native panel is open: its answer belongs to the original uid. + app.run("document.activate", json!({"index": 1})).unwrap(); + let chosen = format!("/elsewhere/Poster.{format}"); + shown.answer(&[&chosen]); + poll(&mut app); + background::wait_all(&mut app); + assert_eq!(app.session.doc().unwrap().uid, uid); + assert_eq!(app.session.doc().unwrap().path.as_deref(), Some(chosen.as_str())); + assert!(!app.session.doc().unwrap().is_dirty()); + assert!(app.session.document_mut(other).unwrap().path.is_none()); + assert_eq!(files.lock().unwrap().attempts, [original, chosen]); + assert!(shown.requests().is_empty()); + } +} + +#[test] +fn readonly_busy_picker_and_save_changes_keep_the_existing_error_flow() { + let (mut app, _) = app("svg", ErrorKind::PermissionDenied); + let (shown, _) = off_the_ui_thread(&mut app.services); + app.run("file.saveAs", json!({})).unwrap_err(); + assert_eq!(shown.requests().len(), 1); + assert!(app.run("file.save", json!({})).is_err()); + normal_error(&app); + assert_eq!(shown.requests().len(), 1); + shown.answer(&[]); + poll(&mut app); + app.ui.dialog = None; + let st = app.session.doc().unwrap(); + app.ui.dialog = Some(crate::state::Dialog::new(crate::unsaved::KIND, json!({"index": 0, "uid": st.uid, "then": "close"}))); + assert!(crate::unsaved::confirm(&mut app).is_err()); + normal_error(&app); + assert!(shown.requests().is_empty()); + assert_eq!(app.session.documents().len(), 1); + assert!(app.session.doc().unwrap().is_dirty()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 644125b545..c4d7c21c1a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -120,7 +120,8 @@ pub use craft_ui::docking::Monitor as DockMonitor; #[cfg(test)] pub(crate) use services::browsable_url; pub use services::{ - DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, ReadFn, RevealFn, Services, WriteFn, + DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, ReadFn, RevealFn, SaveStreamFn, SaveWriteFn, + Services, WriteFn, }; use state::{GeometryStamp, InputGeneration, PendingScreenshot, QueuedScreenshot}; pub(crate) use state::{InputFrame, take_frame_once}; diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 1223e1e426..0e29e99e12 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -122,7 +122,7 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::open_with(app, p), // Saves write through the app (save panel, download); with no path known the panel and the // format's options dialog ask first. - "file.save" => io::save(app, SaveMode::Save, p, true), + "file.save" => io::save_current(app, p), "file.saveAs" => io::save(app, SaveMode::SaveAs, p, true), "file.saveCopy" => io::save(app, SaveMode::Copy, p, true), "file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index a96f13e67c..e21c1f1787 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -40,7 +40,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.save", "Save", "Cmd+S", - "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first", + "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first; ordinary Save to the document's existing file refused by filesystem permissions or a read-only volume opens Save As in Documents with the same name, format and options; cancelling keeps the original path and modified state. Explicit destinations and Close/Quit keep their existing error dialog. Automation gets READ_ONLY_LOCATION: followed by the reason and no panel", ), ( "file.place", diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 4122c19410..080c335d4c 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -188,6 +188,15 @@ pub(crate) fn as_entry(app: &mut VectorcraftApp, entry: impl FnOnce() -> Entr r } +/// Run a continuation under its own entry: a refused Save's panel must resume Save As, not retry +/// the original Save. Restore the enclosing entry for any work it does afterwards. +pub(crate) fn as_own_entry(app: &mut VectorcraftApp, entry: Entry, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R { + let outer = app.picks.entry.replace(entry); + let r = f(app); + app.picks.entry = outer; + r +} + /// [`as_entry`] for a panel's or a dialog's button that `f` handles. pub(crate) fn button(app: &mut VectorcraftApp, f: impl Fn(&mut VectorcraftApp) + 'static) { let f = Rc::new(f); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 901c0a3409..3b0bc7caa9 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -43,6 +43,13 @@ pub type ReadHeadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; +/// A save writer that keeps typed filesystem failures, on either the UI or background thread. +pub type SaveWriteFn = Arc vectorcraft_engine::Result<()> + Send + Sync>; + +/// A typed save writer for native files encoded directly into the output. +pub type SaveStreamFn = + Arc std::io::Result<()>) -> vectorcraft_engine::Result<()> + Send + Sync>; + pub type ExistsFn = Box bool>; pub type Inbox = Arc)>>>; @@ -129,6 +136,10 @@ pub struct Services { /// stream into the file as they are encoded instead of being held whole in memory first. /// Without it they are encoded, then written with `write` / `write_shared`. pub stream_shared: Option, + /// Typed writers for document saves only. Without them the existing string writers are used + /// and failures keep their normal dialog. Downloads never use these callbacks. + pub save_write: Option, + pub save_stream: Option, /// Open a file (or a folder) in the system's default app for it (desktop: Edit Original, Show /// Package). Without it (the web) those answer with an error. pub open_file: Option, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 04715474d0..abf5a0e4cb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,5 +1,7 @@ # Control protocol +> **Last updated:** 2026-11-11 · **Change:** Save location-refusal error (#6146); existing response shapes retained. + > **Last updated:** 2026-10-31 · **Change:** Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). `vectorcraft --control ` listens on `127.0.0.1:` (loopback only). The port must be a number @@ -111,7 +113,7 @@ the two can still win that race. | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | -| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | +| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab Location refusals return `READ_ONLY_LOCATION: ` without a fallback panel for automation; see [Save location errors](#save-location-errors). | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centered in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). In the desktop app the dialog reads its files off the UI thread: `__info` is empty until they are read, `ui.dialog.confirm` returns `{background: true, status}`, `ui.inspect` lists the files still being read in `placing`, and the file is placed (or the cursor loaded) once it is read. With a file, it runs `file.place` centered in the view, at once. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`, `.vcexportpresets`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files (in the desktop app each file is read off the UI thread and placed once it is read, and `ui.inspect` lists it in `placing` until then) and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, width?, height?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in PNG and JPEG files (WebP files store no resolution); `width` or `height` sets the size of each file in pixels instead (`width` wins), and PNG and JPEG files store the resolution that results; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | @@ -1072,3 +1074,24 @@ hidden tab (whose frames the browser pauses) answers when it is shown again. | 2026-10-13 | minor | Documented count, pending-event and per-frame request budgets (#1709). | | 2026-10-13 | minor | Documented transport deadlines and connection-slot recovery (#1726). | | 2026-10-18 | minor | Documented `window.vectorcraft`, the web build's control channel. | + +## Save location errors + +A save refused by the filesystem with PermissionDenied or ReadOnlyFilesystem returns +`READ_ONLY_LOCATION: `. The reason retains the path and system message. This is a +stable prefix within the existing error string, not a new response field or JSON-RPC code. +An automation-root denial, an encoding failure, and an error retaining recovery copies do +not use this code. Choose a writable destination and explicitly save there; automation never +opens the fallback Save As panel. Failed saves keep the original document path and modified +state; multi-file saves may already have written other output files. + +`app.save` with `wait: true` returns the write failure in its reply (`save_file` already asks +for this on the desktop). Without waiting, an accepted background save still replies before +completion; any later failure opens no panel. MCP retains its usual tool-name prefix around +the error text. The interactive fallback applies only to ordinary File > Save, not Close/Quit. + +## Revision history + +| Date | Change | +|---|---| +| 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d42e770df0..c83419fe64 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,5 +1,7 @@ # File format parity +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.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; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.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; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, @@ -15,6 +17,8 @@ from the public specifications, and users' own `.ai` files are used locally only ## Summary +Ordinary File > Save offers Save As when its own location refuses writes with a terminal permission/read-only error (#6146). The picker keeps the filename, format and options and starts in Documents (home or host default if unavailable). Cancel leaves the document modified at its original path. Explicit Save As/Copy/export, recovery failures and Close/Quit keep their existing error dialog. Non-UI saves return `READ_ONLY_LOCATION: ` without opening a panel. Regression execution and native panel acceptance pending; format scores unchanged. + | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| | Formats opened or placed | ~30 | 30 | missing: FreeHand, CorelDRAW, DWG, PICT, PS | @@ -339,6 +343,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | | 2026-11-11 | minor | `.ai` save (PDF-compatible editing copy and legacy) and `.ai`/EPS open: a layer's `Lb` colour index follows the file's numbering, where Magenta, Cyan and Yellow are 4, 5 and 6 and `LAYER_COLORS` has Yellow, Magenta and Cyan; Yellow, Magenta and Cyan layers no longer come out as Magenta, Cyan and Yellow, and the other 24 presets are unchanged (#6502, from the index and RGB pairs in users' files). `LAYER_COLORS` keeps its order, so `.vectorcraft` files and the Layers panel don't change. Scores and estimates unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index b722ace84c..fc003737cc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,7 @@ # Gaps +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (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 @@ -337,7 +339,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi selected with Direct Selection (#1839, #1824); Snap to Grid wins over Snap to Pixel (#1840, #1816 unconfirmed); Redo during a drag keeps the drag (#1856, #1719); a template layer dims its images by Dim Images instead of fading the whole layer (#1864, #1683); a failed save or export is reported in a dialog that names the file and stays until - closed, the document left modified (#1865, #1685). + closed, the document left modified (#1865, #1685). Ordinary File > Save now offers Save As for terminal permission/read-only refusals (#6146), unless another picker is open; recovery errors retain the dialog. Regression execution pending; G11 estimate unchanged. - **Done:** the Shape Builder offers the empty areas closed shapes enclose as regions, such as the middle of four circles that touch, and merges them with the regions around them (#1691). - **Done (#2523):** Shape Builder Gap Detection shares Live Paint's spatially indexed gap finder, @@ -854,6 +856,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | G5: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Missing trimmed; estimate 5–15 → 4–14 h; MCP Brush libraries list Energy Beams. | | 2026-11-11 | minor | G5: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); Missing trimmed; estimate 6–16 → 5–15 h; MCP Symbol libraries list Skill Icons. | | 2026-11-10 | minor | G6: MCP catalogue (#6460): headless lists `UI_COMMANDS` (`ui: true`), eight prompts, effect vs command ids, 33 tools, commands/reference; estimate unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..d8e00fbdb1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,5 +1,7 @@ # VectorCraft MCP server +> **Last updated:** 2026-11-11 · **Change:** Save location-refusal error (#6146); existing response shapes retained. + `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 draw, inspect and look at VectorCraft documents. @@ -491,7 +493,7 @@ opens the picker through `run_command` with the remote backend. | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | | `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | -| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | +| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. Location refusals return `READ_ONLY_LOCATION: ` without a fallback panel for automation; see [Save location errors](#save-location-errors). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | @@ -9031,3 +9033,24 @@ Path Options' Preview does (#5360). {"name":"run_command","arguments":{"command":"addon.type.releasePath","params":{}}} {"name":"run_command","arguments":{"command":"addon.type.releasePath","params":{"mode":"path","keepOriginal":true}}} ``` + +## Save location errors + +A save refused by the filesystem with PermissionDenied or ReadOnlyFilesystem returns +`READ_ONLY_LOCATION: `. The reason retains the path and system message. This is a +stable prefix within the existing error string, not a new response field or JSON-RPC code. +An automation-root denial, an encoding failure, and an error retaining recovery copies do +not use this code. Choose a writable destination and explicitly save there; automation never +opens the fallback Save As panel. Failed saves keep the original document path and modified +state; multi-file saves may already have written other output files. + +`app.save` with `wait: true` returns the write failure in its reply (`save_file` already asks +for this on the desktop). Without waiting, an accepted background save still replies before +completion; any later failure opens no panel. MCP retains its usual tool-name prefix around +the error text. The interactive fallback applies only to ordinary File > Save, not Close/Quit. + +## Revision history + +| Date | Change | +|---|---| +| 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 1b55740614..4f75ce5008 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # Roadmap: milestones and what's next +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (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) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). @@ -143,7 +145,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Save refusal continuation (#6146): ordinary Save offers Save As; cancellation/automation isolation; validation pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); 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 | 3–6 | | 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, 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 | @@ -190,6 +192,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged | | 2026-11-11 | minor | M10/G5: Skill Icons built-in symbol library; MCP docs catch-up; estimates unchanged | | 2026-11-10 | minor | M10/G5: Status Effects built-in graphic style library; MCP docs catch-up for UI Frames and Map Markers; estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..46059e55f9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,5 +1,7 @@ # Parity with Adobe Illustrator +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** 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) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). @@ -238,7 +240,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Save refusal continuation (#6146): `READ_ONLY_LOCATION`, Save As on ordinary UI Save only; regression execution pending; score/hours unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); Control bar snapping quick access includes Snap to Glyph and Alignment Guides (#6164); new ruler-guide Alt orientation implemented; global/artboard ruler modes (`view.globalRulers`) and video rulers (View › Rulers › Show Video Rulers, #2849) done; status-bar X/Y uses the active ruler origin and General units; score unchanged | @@ -551,6 +553,8 @@ since 2026-10-01. ## Evidence: what ships today, by area +- **Save refusal (#6146):** ordinary File > Save to its own location offers Save As after a terminal permission/read-only refusal, preserving name, format and options. Cancel keeps the failed-save state. Busy pickers, recovery-copy errors, other failures, explicit destinations and Close/Quit keep the existing dialog. Engine, UI and MCP regression cases are in `cmd/fileio/tests_readonly.rs`, `io/tests_read_only_save.rs` and MCP `tests/fileio.rs`; execution and native panel acceptance pending. Scores and hours unchanged. + - **PDF authored replacement text (#6165):** `crates/pdf/src/tests/actual_text.rs` covers replacement once with suffix placement, PDF text-string decoding, empty and nested owners, sibling spans, page reset, multiple text-show operations, style/line changes, fill-and-stroke, reader windows and bounded input. Authored logical-order stretches are protected while adjacent ordinary text still undergoes reconstruction. The replacement takes its first placed glyph's style and line; its characters are not redistributed over later lines/styles. Exact mixed-direction paragraph layout is not claimed. No export changes; scores and hours stay at 75% / 10–20 h. @@ -1981,6 +1985,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. | @@ -2367,4 +2372,4 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s 2026-10-28 Flare interaction: the center drag turns only the rays by one degree per document point of increasing pointer y and back at the same rate for decreasing y. This is an application rule independent of event frequency. Shift keeps the current turn; Space during either drag moves the flare without changing its size or turn, including the first drag sample. Releasing a modifier resumes from the current pointer position without catching up skipped rotation. Scores and remaining estimates unchanged. ## EPS Save evidence (2026-10-26) -Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. \ No newline at end of file +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 296826671f..9b654cc363 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,5 +1,7 @@ # UI and interaction parity +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: 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; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; 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; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). @@ -363,6 +365,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Interaction | Status | Evidence | |---|---|---| +| Ordinary Save refused by filesystem permissions/read-only storage offers Save As | implemented; validation pending | #6146; `io::tests_read_only_save`; same name/format/options, Documents folder, UID-bound continuation; cancellation and automation isolation | | Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | | A document tab dragged along the tab strip moves its document; a line marks where it goes, the current document and every document's view stay as they were, and a drag off the strip still floats it | done | #5663, [public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html); `document.move`; `tests::tab_reorder`, `chrome::tests` (drop index with floating and view tabs), `engine` `tests::document_move` | | A middle-click on a document tab or its × closes it as the × does; closing a modified document's last view asks to save first (VectorCraft addition, as in PhotoCraft) | done | #6506; `chrome::tests::a_middle_click_on_a_tab_closes_it_as_the_close_box_does`; not scored | @@ -511,6 +514,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | | 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | From 9a77c2ee11750528ce55d3fc5e4129423a30fc09 Mon Sep 17 00:00:00 2001 From: Ahmed Gomaa Date: Thu, 12 Nov 2026 01:27:25 +0200 Subject: [PATCH 11/79] Save As fallback asks for a changed format's options When the read-only fallback's Save As panel returns a different format, drop the options captured for the original format and open the picked format's normal Save options; an unchanged format keeps its options. Regenerate the file.save entry in the MCP command catalogue. --- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/io/save.rs | 8 +++++++- crates/ui-egui/src/io/tests_read_only_save.rs | 15 +++++++++++++++ docs/file-format-parity.md | 5 +++-- docs/roadmap.md | 5 +++-- docs/target-app-parity.md | 5 +++-- docs/ui-parity.md | 5 +++-- 7 files changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index e15244580d..2fc406440f 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -39,7 +39,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.save", "Save", "Cmd+S", - "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first", + "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first; ordinary Save to the document's existing file refused by filesystem permissions or a read-only volume opens Save As in Documents with the same name, format and options; cancelling keeps the original path and modified state. Explicit destinations and Close/Quit keep their existing error dialog. Automation gets READ_ONLY_LOCATION: followed by the reason and no panel", ), ( "file.place", diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index 5efe776d64..03a03b4c3a 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -315,17 +315,23 @@ pub(super) fn save_suggesting( filters, }; let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &pick.name, file_type, first.format, mode))?; + // Only the read-only retry carries options for the original format through Save As. + // A different format must use its own normal options flow instead. + let changed_retry_format = suggestion.is_some() && fileio::format_for_name(&picked).is_some_and(|f| f.id != first.format.id); // The picked file type wins over a `format` param. let mut q = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = q.as_object_mut() { o.remove("format"); + if changed_retry_format { + o.remove("options"); + } o.insert("path".into(), json!(picked)); } let chosen = plan(app, mode, &acknowledged(&q))?; if let Some(r) = chosen.path.as_deref().and_then(|path| ask_before_losing(app, path, save_command(mode), &q)) { return Ok(r); } - if ask && asks_options(mode, chosen.format) { + if (ask || changed_retry_format) && asks_options(mode, chosen.format) { let path = chosen.path.clone().unwrap_or_default(); let r = ask_format_options(app, mode, chosen.format, &path); crate::dialogs::opened_by_panel(app); diff --git a/crates/ui-egui/src/io/tests_read_only_save.rs b/crates/ui-egui/src/io/tests_read_only_save.rs index 8a01003a90..6568a7e281 100644 --- a/crates/ui-egui/src/io/tests_read_only_save.rs +++ b/crates/ui-egui/src/io/tests_read_only_save.rs @@ -264,3 +264,18 @@ fn readonly_busy_picker_and_save_changes_keep_the_existing_error_flow() { assert_eq!(app.session.documents().len(), 1); assert!(app.session.doc().unwrap().is_dirty()); } + +#[test] +fn readonly_changed_format_asks_for_its_options_before_writing() { + let (mut app, files) = app("svg", ErrorKind::PermissionDenied); + let before = state(&app); + let (shown, _) = off_the_ui_thread(&mut app.services); + assert_eq!(app.run("file.save", json!({})).unwrap()["pending"], "fileDialog"); + shown.answer(&["/elsewhere/Poster.pdf"]); + poll(&mut app); + let dialog = app.ui.dialog.as_ref().expect("the picked format's Save dialog"); + assert_eq!(dialog.kind, "savePdf"); + assert_eq!(dialog.str("path"), "/elsewhere/Poster.pdf"); + assert_eq!(state(&app), before); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg"]); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c83419fe64..f8261eef13 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. +> **Last updated:** 2026-11-12 · **Change:** Save refusal follow-up (#6146): changing format in fallback Save As opens that format’s options; unchanged format keeps the original options. Focused UI regressions passed; native panel acceptance pending; scores and estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.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; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.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; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) @@ -17,7 +17,7 @@ from the public specifications, and users' own `.ai` files are used locally only ## Summary -Ordinary File > Save offers Save As when its own location refuses writes with a terminal permission/read-only error (#6146). The picker keeps the filename, format and options and starts in Documents (home or host default if unavailable). Cancel leaves the document modified at its original path. Explicit Save As/Copy/export, recovery failures and Close/Quit keep their existing error dialog. Non-UI saves return `READ_ONLY_LOCATION: ` without opening a panel. Regression execution and native panel acceptance pending; format scores unchanged. +Ordinary File > Save offers Save As when its own location refuses writes with a terminal permission/read-only error (#6146). The picker suggests the original filename and format, keeps the original options when the format is unchanged, and opens the chosen format’s Save options when it changes. It starts in Documents (home or host default if unavailable). Cancel leaves the document modified at its original path. Explicit Save As/Copy/export, recovery failures and Close/Quit keep their existing error dialog. Non-UI saves return `READ_ONLY_LOCATION: ` without opening a panel. Focused UI regressions passed; engine regression execution and native panel acceptance pending; format scores unchanged. | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| @@ -343,6 +343,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | diff --git a/docs/roadmap.md b/docs/roadmap.md index 4f75ce5008..67a5a17a1a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. +> **Last updated:** 2026-11-12 · **Change:** Save refusal follow-up (#6146): changing format in fallback Save As opens that format’s options; unchanged format keeps the original options. Focused UI regressions passed; native panel acceptance pending; scores and estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (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) > @@ -145,7 +145,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Save refusal continuation (#6146): ordinary Save offers Save As; cancellation/automation isolation; validation pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); 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 | 3–6 | | 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, 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 | @@ -192,6 +192,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged | | 2026-11-11 | minor | M10/G5: Skill Icons built-in symbol library; MCP docs catch-up; estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 46059e55f9..fb42bb6a8c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. +> **Last updated:** 2026-11-12 · **Change:** Save refusal follow-up (#6146): changing format in fallback Save As opens that format’s options; unchanged format keeps the original options. Focused UI regressions passed; native panel acceptance pending; scores and estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** 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) > @@ -553,7 +553,7 @@ since 2026-10-01. ## Evidence: what ships today, by area -- **Save refusal (#6146):** ordinary File > Save to its own location offers Save As after a terminal permission/read-only refusal, preserving name, format and options. Cancel keeps the failed-save state. Busy pickers, recovery-copy errors, other failures, explicit destinations and Close/Quit keep the existing dialog. Engine, UI and MCP regression cases are in `cmd/fileio/tests_readonly.rs`, `io/tests_read_only_save.rs` and MCP `tests/fileio.rs`; execution and native panel acceptance pending. Scores and hours unchanged. +- **Save refusal (#6146):** ordinary File > Save to its own location offers Save As after a terminal permission/read-only refusal, suggesting the original name and format, preserving options for the unchanged format and opening the chosen format’s Save options on a change. Cancel keeps the failed-save state. Busy pickers, recovery-copy errors, other failures, explicit destinations and Close/Quit keep the existing dialog. Engine, UI and MCP regression cases are in `cmd/fileio/tests_readonly.rs`, `io/tests_read_only_save.rs` and MCP `tests/fileio.rs`; focused UI regressions passed; engine regression execution and native panel acceptance pending. Scores and hours unchanged. - **PDF authored replacement text (#6165):** `crates/pdf/src/tests/actual_text.rs` covers replacement once with suffix placement, PDF text-string decoding, empty and nested owners, sibling spans, page reset, multiple text-show operations, style/line changes, fill-and-stroke, reader windows and bounded input. Authored logical-order stretches are protected while adjacent ordinary text still undergoes reconstruction. The replacement takes its first placed glyph's style and line; its characters are not redistributed over later lines/styles. Exact mixed-direction paragraph layout is not claimed. No export changes; scores and hours stay at 75% / 10–20 h. @@ -1985,6 +1985,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9b654cc363..dc580549cb 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. +> **Last updated:** 2026-11-12 · **Change:** Save refusal follow-up (#6146): changing format in fallback Save As opens that format’s options; unchanged format keeps the original options. Focused UI regressions passed; native panel acceptance pending; scores and estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: 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; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; 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; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > @@ -365,7 +365,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Interaction | Status | Evidence | |---|---|---| -| Ordinary Save refused by filesystem permissions/read-only storage offers Save As | implemented; validation pending | #6146; `io::tests_read_only_save`; same name/format/options, Documents folder, UID-bound continuation; cancellation and automation isolation | +| Ordinary Save refused by filesystem permissions/read-only storage offers Save As | focused UI regressions passed; native acceptance pending | #6146; `io::tests_read_only_save`; same name/format suggested; unchanged format keeps options, changed format opens its Save options; Documents folder, UID-bound continuation; cancellation and automation isolation | | Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | | A document tab dragged along the tab strip moves its document; a line marks where it goes, the current document and every document's view stay as they were, and a drag off the strip still floats it | done | #5663, [public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html); `document.move`; `tests::tab_reorder`, `chrome::tests` (drop index with floating and view tabs), `engine` `tests::document_move` | | A middle-click on a document tab or its × closes it as the × does; closing a modified document's last view asks to save first (VectorCraft addition, as in PhotoCraft) | done | #6506; `chrome::tests::a_middle_click_on_a_tab_closes_it_as_the_close_box_does`; not scored | @@ -514,6 +514,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | From e05f40d7f8c8023408b96a5166e22025352524f6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:41:24 -0800 Subject: [PATCH 12/79] Type: Center on Top and Center on Bottom for type on a closed path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type › Type on a Path › Center on Top and Center on Bottom (type.pathCenter.top and type.pathCenter.bottom, also on the Contextual Task Bar for type on a closed path) slide the type along its path so that the middle of the span between its brackets lands on the path's topmost or bottommost point as the object is drawn, after its transform. On a level stretch, such as a rectangle's top edge, the middle of the stretch is used. The span keeps its length, and the first paragraph is set to Center unless it is Center or Justify Center. The type is not flipped. Type on an open or compound path, type in a thread, and type on a path with no length or no height are refused with an error that names them. When the type is already centered there, no undo step is recorded. This is VectorCraft's own behavior. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/plugin_call/reviewed.rs | 2 + crates/engine/src/cmd/typemenu.rs | 148 +++++++++++++++ crates/engine/src/tests/pathtype.rs | 178 ++++++++++++++++++ crates/geom/src/arc.rs | 134 ++++++++++++- crates/ui-egui/src/canvas/task_bar.rs | 13 ++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 6 + crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 6 + crates/ui-egui/src/i18n/el.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/fi.tsv | 6 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/hi.tsv | 6 + crates/ui-egui/src/i18n/hu.tsv | 6 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/ka.tsv | 6 + crates/ui-egui/src/i18n/ko.tsv | 6 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 6 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/sv.tsv | 6 + crates/ui-egui/src/i18n/tr.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 6 + crates/ui-egui/src/i18n/vi.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + crates/ui-egui/src/menus/tests.rs | 52 +++++ crates/ui-egui/src/menus/tree.rs | 8 +- crates/ui-egui/src/tests/taskbar.rs | 28 +++ docs/mcp.md | 20 ++ docs/target-app-parity.md | 1 + 37 files changed, 724 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 541389bb89..07ff0ded2e 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -638,6 +638,8 @@ pub(super) const ALLOWED: &[&str] = &[ "type.opticalMarginAlignment", "type.orientation.horizontal", "type.orientation.vertical", + "type.pathCenter.bottom", + "type.pathCenter.top", "type.pathOptions", "type.resetHorizontalScale", "type.resetTracking", diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index deaac8acc4..e9fd191676 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -60,6 +60,24 @@ pub fn specs() -> Vec { has_selection, path_options ), + cmd!( + "type.pathCenter.top", + "Center on Top", + ["Type", "Type on a Path"], + None, + "{ids?|id?} slide the selected type on a closed path along it so that the middle of the span between its start and end brackets lands on the path's topmost point as the object is drawn (after its transform, so a rotated, scaled or skewed path is measured as it appears; on a level stretch such as a rectangle's top edge, its middle; with several separate topmost points, the middle of the shortest part of the path that holds them all), keeping the span's length (a full turn stays a full turn), and set the first paragraph's alignment to Center unless it is Center or Justify Center, so the type is centered there. Point type and area type in the selection are left as they are. Type on an open or compound path, type in a thread, and type on a path with no length or no height are refused with an error that names them, and nothing changes. When every object is already centered there, nothing changes and no undo step is recorded → {ids, start, end} (the first object's brackets)", + has_closed_path_type, + path_center_top + ), + cmd!( + "type.pathCenter.bottom", + "Center on Bottom", + ["Type", "Type on a Path"], + None, + "{ids?|id?} as type.pathCenter.top, on the path's bottommost point as drawn. The type is not flipped: on the bottom of a path that runs clockwise it reads upside down until Type on a Path Options › Flip turns it → {ids, start, end} (the first object's brackets)", + has_closed_path_type, + path_center_bottom + ), cmd!( "type.dynamicText", "Dynamic Text", @@ -410,6 +428,136 @@ fn to_point(s: &mut Session, p: &Value) -> Result { Ok(ids_json(&ids)) } +/// Whether type on a path `t` runs along one closed path (no open or compound path). +fn on_one_closed_path(t: &TextObject) -> bool { + matches!(&t.kind, TextKind::OnPath { path, .. } if path.subpaths.len() == 1 && path.is_closed()) +} + +/// Why `type.pathCenter.top` and `.bottom` refuse type on a path `ids`: those on an open or +/// compound path, then those in a thread; None when they take every one. +fn path_center_refusal(d: &Document, ids: &[NodeId]) -> Option { + let open = ids.iter().filter(|id| !path_text(d.node(**id)).is_some_and(on_one_closed_path)).map(|id| id.0.to_string()).collect::>(); + if !open.is_empty() { + let open = open.join(", "); + return Some(format!("type on an open or compound path cannot be centered on its top or bottom: {open}")); + } + let frames = threaded_ids(d, ids); + (!frames.is_empty()).then(|| format!("type on a path in a thread cannot be centered on its top or bottom: {frames}")) +} + +/// `type.pathCenter.top` and `.bottom` are enabled for a selection holding type on a path, all of +/// it on one closed path and none of it in a thread. +fn has_closed_path_type(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or("no document open")?; + let d = &st.doc; + let selected: Vec = st.selection.objects.to_vec(); + let paths: Vec = text_ids(d, &selected).into_iter().filter(|id| path_text(d.node(*id)).is_some()).collect(); + if paths.is_empty() { + return Err("select type on a closed path".into()); + } + path_center_refusal(d, &paths).map_or(Ok(()), Err) +} + +/// The brackets that center type on a path `t` on its path's topmost point (`bottom`: its +/// bottommost) as `t.xf` draws it, its span keeping its length; None for a path with no length or +/// no height. +fn centered_brackets(t: &TextObject, bottom: bool) -> Option<(f64, Option)> { + let TextKind::OnPath { path, end, .. } = &t.kind else { return None }; + let (from, to) = t.kind.path_span()?; + let arc = vectorcraft_geom::ArcPath::new(path); + if arc.is_empty() { + return None; + } + let flip = if bottom { Affine::FLIP_Y } else { Affine::IDENTITY }; + let middle = arc.topmost(flip * t.xf)? / arc.len(); + let run = to - from; + let start = (middle - run * 0.5).rem_euclid(1.0).clamp(0.0, 1.0); + // Without an end bracket the type runs once round; a full turn's end stays on its start. + let end = end.map(|_| if run >= 1.0 - 1e-12 { start } else { (start + run).rem_euclid(1.0).clamp(0.0, 1.0) }); + Some((start, end)) +} + +/// Whether brackets `a` and `b`, fractions of a closed path's length, are at one place within +/// rounding (0 and 1 are one place). +fn same_bracket(a: f64, b: f64) -> bool { + let d = (a - b).rem_euclid(1.0); + d.min(1.0 - d) <= 1e-9 +} + +fn path_center_top(s: &mut Session, p: &Value) -> Result { + path_center(s, p, false) +} + +fn path_center_bottom(s: &mut Session, p: &Value) -> Result { + path_center(s, p, true) +} + +/// Type › Type on a Path › Center on Top and Center on Bottom: slide the selected type on closed +/// paths along them so the middle of each span lands on the path's top (`bottom`: its bottom) as +/// drawn, and center the first paragraph, in one undo step; none when nothing changes. +fn path_center(s: &mut Session, p: &Value, bottom: bool) -> Result { + let c = if bottom { "type.pathCenter.bottom" } else { "type.pathCenter.top" }; + let centered = |j: Justify| matches!(j, Justify::Center | Justify::JustifyCenter); + // Each object the command changes, with its new brackets when they move and whether its first + // paragraph is set to Center. + let (ids, changes) = { + let d = &s.doc()?.doc; + let ids: Vec = texts(s, p, c)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect(); + if ids.is_empty() { + return Err(bad(c, "select type on a closed path")); + } + if let Some(why) = path_center_refusal(d, &ids) { + return Err(bad(c, why)); + } + let (mut changes, mut flat) = (vec![], vec![]); + for id in &ids { + let Some(t) = path_text(d.node(*id)) else { continue }; + let TextKind::OnPath { start, end, .. } = &t.kind else { continue }; + let Some((to_start, to_end)) = centered_brackets(t, bottom) else { + flat.push(id.0.to_string()); + continue; + }; + let stays = same_bracket(*start, to_start) + && match (end, to_end) { + (None, None) => true, + (Some(a), Some(b)) => same_bracket(*a, b), + _ => false, + }; + let align = !centered(t.para_at(0).justify); + if !stays || align { + changes.push((*id, (!stays).then_some((to_start, to_end)), align)); + } + } + if !flat.is_empty() { + let flat = flat.join(", "); + return Err(bad(c, format!("type on a path with no length or no height cannot be centered: {flat}"))); + } + (ids, changes) + }; + if !changes.is_empty() { + s.edit(if bottom { "Center on Bottom" } else { "Center on Top" }, |d, _| { + for (id, brackets, align) in &changes { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + if let (Some(b), TextKind::OnPath { start, end, .. }) = (brackets, &mut t.kind) { + (*start, *end) = *b; + } + if *align { + t.edit_paras(Some(0..1), |para| para.justify = Justify::Center); + } + refresh_bounds(t); + } + Ok(()) + })?; + } + let d = &s.doc()?.doc; + let (start, end) = ids.first().and_then(|id| path_text(d.node(*id))).map_or((0.0, None), |t| match &t.kind { + TextKind::OnPath { start, end, .. } => (*start, *end), + _ => (0.0, None), + }); + Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>(), "start": start, "end": end})) +} + /// The options `type.pathOptions` sets; with none of them given it queries. const PATH_OPTIONS: [&str; 6] = ["start", "end", "flip", "effect", "alignToPath", "spacing"]; diff --git a/crates/engine/src/tests/pathtype.rs b/crates/engine/src/tests/pathtype.rs index 5ead0cbd22..8aa8c05684 100644 --- a/crates/engine/src/tests/pathtype.rs +++ b/crates/engine/src/tests/pathtype.rs @@ -184,6 +184,184 @@ fn a_flip_round_a_closed_path_keeps_the_type_in_place() { assert!(origins(&s, id)[0].distance(first) < 8.0); } +/// A circle of radius 100 round (200, 200), starting at 9 o'clock and running clockwise, with +/// type on it from its start. +fn on_circle(s: &mut Session, text: &str) -> NodeId { + path_type(s, "M100 200 A100 100 0 1 1 300 200 A100 100 0 1 1 100 200 Z", [90.0, 200.0], text) +} + +#[test] +fn center_on_top_and_bottom_of_a_circle() { + let mut s = session(); + let id = on_circle(&mut s, "OOOO"); + assert!(near(brackets(&s, id).0, 0.0), "{:?}", brackets(&s, id)); + let steps = undo_len(&s); + let r = s.execute("type.pathCenter.top", &json!({})).unwrap(); + // Once round, its middle at 12 o'clock, a quarter of the way round: the type sits on the top, centered. + let (start, end) = brackets(&s, id); + assert!(near(start, 0.75) && end.is_none(), "{start} {end:?}"); + assert_eq!((r["ids"].clone(), r["end"].clone()), (json!([id.0]), Value::Null)); + assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::Center); + let top = ink(&s, id); + assert!((top.center().x - 200.0).abs() < 0.5 && top.y1 < 101.0, "{top:?}"); + assert_eq!((undo_len(&s), last_step(&s)), (steps + 1, "Center on Top".to_string())); + // Again: nothing moves, and no undo step is recorded. + s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert_eq!(undo_len(&s), steps + 1); + // The bottom: 6 o'clock, outside the circle (the type is not flipped). + s.execute("type.pathCenter.bottom", &json!({})).unwrap(); + let (start, end) = brackets(&s, id); + assert!(near(start, 0.25) && end.is_none(), "{start} {end:?}"); + let bottom = ink(&s, id); + assert!((bottom.center().x - 200.0).abs() < 0.5 && bottom.y0 > 299.0, "{bottom:?}"); + assert_eq!((undo_len(&s), last_step(&s)), (steps + 2, "Center on Bottom".to_string())); + // Justify Center stays as it is. + s.execute("text.setStyle", &json!({"justify": "justifyCenter"})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::JustifyCenter); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(brackets(&s, id).0, 0.25)); + // Brackets already in place with another alignment: only the alignment changes, in one step. + s.execute("text.setStyle", &json!({"justify": "left"})).unwrap(); + let (steps, before) = (undo_len(&s), brackets(&s, id)); + s.execute("type.pathCenter.bottom", &json!({})).unwrap(); + assert_eq!((undo_len(&s), brackets(&s, id)), (steps + 1, before)); + assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::Center); +} + +#[test] +fn center_on_top_twice_records_one_step_for_any_span() { + let mut s = session(); + let id = on_circle(&mut s, "OOOO"); + for (start, end) in [(0.0, 0.2), (0.13, 0.41), (0.37, 0.52), (0.61, 0.97), (0.89, 0.07), (0.3, 0.3001)] { + s.execute("type.pathOptions", &json!({"start": start, "end": end})).unwrap(); + for c in ["type.pathCenter.top", "type.pathCenter.bottom"] { + s.execute(c, &json!({})).unwrap(); + let (steps, placed) = (undo_len(&s), brackets(&s, id)); + // Again: the brackets stay exactly where they are, and no undo step is recorded. + s.execute(c, &json!({})).unwrap(); + assert_eq!((undo_len(&s), brackets(&s, id)), (steps, placed), "{c} {start} {end}"); + } + } +} + +#[test] +fn center_on_top_keeps_the_span_and_measures_the_path_as_drawn() { + let mut s = session(); + let id = on_circle(&mut s, "OOOO"); + // A span of a fifth of the path keeps its length. + s.execute("type.pathOptions", &json!({"end": 0.2})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + let (start, end) = brackets(&s, id); + assert!(near(start, 0.15) && end.is_some_and(|e| near(e, 0.35)), "{start} {end:?}"); + // Turned a quarter turn counter-clockwise, its 3 o'clock point, halfway round, is at the top. + s.execute("type.pathOptions", &json!({"start": 0, "end": null})).unwrap(); + s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + let (start, _) = brackets(&s, id); + assert!(near(start.rem_euclid(1.0), 0.0) || near(start, 1.0), "{start}"); + let bounds = text(&s, id).bounds().unwrap(); + let top = ink(&s, id); + assert!((top.center().x - bounds.center().x).abs() < 0.5 && top.y1 < bounds.y0 + 1.0, "{top:?} {bounds:?}"); + // A rectangle: the middle of its top edge. + let id = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [90.0, 100.0], "OOOO"); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + let (start, end) = brackets(&s, id); + assert!(near(start, 0.625) && end.is_none(), "{start} {end:?}"); + assert!((ink(&s, id).center().x - 200.0).abs() < 0.5); +} + +#[test] +fn center_on_top_moves_every_selected_type_on_a_path_in_one_step() { + let mut s = session(); + let a = on_circle(&mut s, "OOOO"); + let b = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [90.0, 100.0], "OOOO"); + let p = s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0, p]})).unwrap(); + let steps = undo_len(&s); + let r = s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([a.0, b.0])); + assert!(near(brackets(&s, a).0, 0.75) && near(brackets(&s, b).0, 0.625)); + assert_eq!(undo_len(&s), steps + 1); + // Dynamic Text stays on, and the layout fits the text to the span where it now runs: a fifth of + // the circle, at its bottom. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("type.pathOptions", &json!({"start": 0, "end": 0.2})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + s.execute("type.pathCenter.bottom", &json!({})).unwrap(); + assert!(text(&s, a).dynamic_text && near(brackets(&s, a).0, 0.65), "{:?}", brackets(&s, a)); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, a)); + assert!(lay.fit_scale > 1.5 && !lay.overflow, "{}", lay.fit_scale); + let bottom = ink(&s, a); + assert!((bottom.center().x - 200.0).abs() < 1.0 && bottom.y1 > 300.0, "{bottom:?}"); + // Vertical type on a path moves the same way. + s.execute("type.pathOptions", &json!({"start": 0, "end": null})).unwrap(); + s.execute("type.orientation.vertical", &json!({})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert!(near(brackets(&s, a).0, 0.75)); +} + +#[test] +fn center_on_top_refuses_open_compound_and_threaded_type() { + let mut s = session(); + let circle = on_circle(&mut s, "OOOO"); + let line = on_line(&mut s); + s.execute("select.set", &json!({"ids": [circle.0, line.0]})).unwrap(); + let steps = undo_len(&s); + let before = brackets(&s, circle); + let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string(); + assert!(e.contains("type on an open or compound path cannot be centered on its top or bottom") && e.ends_with(&line.0.to_string()), "{e}"); + // Nothing changes, the type on the circle included, and the same holds for an explicit list. + assert_eq!((undo_len(&s), brackets(&s, circle)), (steps, before)); + let e = s.execute("type.pathCenter.bottom", &json!({"ids": [circle.0, line.0]})).unwrap_err().to_string(); + assert!(e.contains("open or compound path") && e.ends_with(&line.0.to_string()), "{e}"); + s.execute("select.set", &json!({"ids": [circle.0]})).unwrap(); + for bad in [json!({"ids": "all"}), json!({"ids": [999_999]})] { + assert!(s.execute("type.pathCenter.top", &bad).is_err(), "{bad}"); + } + assert_eq!(undo_len(&s), steps); + // Two circles side by side in one path: a compound path. + let pair = path_type( + &mut s, + "M100 450 A40 40 0 1 1 180 450 A40 40 0 1 1 100 450 Z M300 450 A40 40 0 1 1 380 450 A40 40 0 1 1 300 450 Z", + [90.0, 450.0], + "OOOO", + ); + let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string(); + assert!(e.contains("open or compound path") && e.ends_with(&pair.0.to_string()), "{e}"); + // A closed path that is level all round has no height. + let flat = path_type(&mut s, "M100 400 L300 400 Z", [90.0, 400.0], "OOOO"); + let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string(); + assert!(e.contains("type on a path with no length or no height cannot be centered") && e.ends_with(&flat.0.to_string()), "{e}"); + // A frame of a thread is refused, alone or with other type on a closed path. + let threaded = on_circle(&mut s, "Threaded story"); + let frame = s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [threaded.0, frame]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [threaded.0]})).unwrap(); + let before = brackets(&s, threaded); + for ids in [json!([threaded.0]), json!([circle.0, threaded.0])] { + let steps = undo_len(&s); + let e = s.execute("type.pathCenter.top", &json!({ "ids": ids })).unwrap_err().to_string(); + assert!(e.contains("type on a path in a thread cannot be centered on its top or bottom") && e.ends_with(&threaded.0.to_string()), "{e}"); + assert_eq!((undo_len(&s), brackets(&s, threaded)), (steps, before)); + } + // Without type on one closed path selected, the commands are not available. + let p = s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); + for (ids, why) in [ + (json!([p]), "select type on a closed path"), + (json!([line.0]), "open or compound path"), + (json!([circle.0, line.0]), "open or compound path"), + (json!([threaded.0]), "in a thread"), + ] { + s.execute("select.set", &json!({ "ids": ids })).unwrap(); + for c in ["type.pathCenter.top", "type.pathCenter.bottom"] { + let e = s.execute(c, &json!({})).unwrap_err().to_string(); + assert!(e.contains(why), "{c} {ids}: {e}"); + } + } +} + #[test] fn align_to_path_and_spacing() { let mut s = session(); diff --git a/crates/geom/src/arc.rs b/crates/geom/src/arc.rs index c4fe640ae2..644dc44f6a 100644 --- a/crates/geom/src/arc.rs +++ b/crates/geom/src/arc.rs @@ -2,7 +2,7 @@ //! distance along it of the point nearest another (type on a path lays its glyphs out and the //! selection tools drag its brackets by distance along the path). -use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest, PathSeg, Point, Vec2}; +use kurbo::{Affine, ParamCurve, ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest, PathSeg, Point, Vec2}; use crate::PathData; @@ -76,6 +76,78 @@ impl ArcPath { pub fn fraction_at(&self, p: Point) -> Option { (self.len > 0.0).then(|| self.nearest(p)).flatten().map(|s| (s / self.len).clamp(0.0, 1.0)) } + + /// The distance along the path of the middle of its topmost points (the least y) where `xf` + /// draws it: the topmost point when there is one, the middle of a level stretch (a + /// rectangle's top edge), and when several points or stretches are topmost, the middle of the + /// shortest part of the path that holds them all. None for a path with no length, or a closed + /// path whose every point is topmost. + pub fn topmost(&self, xf: Affine) -> Option { + let drawn: Vec<(PathSeg, PathSeg, f64, f64)> = self.segs.iter().map(|&(s, c, l)| (s, xf * s, c, l)).collect(); + // The candidates for the least y in each segment: its ends and where it turns. + let turns = |d: &PathSeg| [0.0, 1.0].into_iter().chain(d.extrema()).collect::>(); + let mut bounds: Option = None; + let mut top = f64::INFINITY; + for (_, d, _, _) in &drawn { + let b = d.bounding_box(); + bounds = Some(bounds.map_or(b, |a| a.union(b))); + top = turns(d).into_iter().map(|t| d.eval(t).y).fold(top, f64::min); + } + let b = bounds?; + if !(top.is_finite() && b.width().is_finite() && b.height().is_finite()) { + return None; + } + // Points this close to the top count as topmost: a part of the drawn size, and no less than + // the rounding error of coordinates this far from the origin. + let tol = (1e-9 * (b.width() + b.height())).max(64.0 * f64::EPSILON * b.y0.abs().max(b.y1.abs())); + // Stretches this close along the path meet. + let near = 1e-9 * self.len; + // The topmost points as stretches of distance along the path, in order. + let mut runs: Vec<(f64, f64)> = vec![]; + for (s, d, c, l) in &drawn { + let db = d.bounding_box(); + if db.y1 - db.y0 <= tol && db.y0 <= top + tol { + runs.push((*c, c + l)); + continue; + } + for t in turns(d) { + if d.eval(t).y <= top + tol { + let at = c + s.subsegment(0.0..t).arclen(ACCURACY).min(*l); + runs.push((at, at)); + } + } + } + runs.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Stretches that meet or overlap are one. + let mut merged: Vec<(f64, f64)> = vec![]; + for (a, z) in runs { + match merged.last_mut() { + Some(last) if a <= last.1 + near => last.1 = last.1.max(z), + _ => merged.push((a, z)), + } + } + let (first, last) = (*merged.first()?, *merged.last()?); + if !self.closed { + return Some((first.0 + last.1) * 0.5); + } + let len = self.len; + // Round a closed path, the shortest part holding every stretch leaves out the longest + // gap between two of them (the gap from the last round to the first included). + let wrap = first.0 + len - last.1; + let (mut gap, mut from, mut to) = (wrap, first.0, last.1); + for w in merged.windows(2) { + if let [a, b] = w + && b.0 - a.1 > gap + { + (gap, from, to) = (b.0 - a.1, b.0, a.1); + } + } + if gap <= near { + return None; + } + let run = (to - from).rem_euclid(len); + Some((from + run * 0.5).rem_euclid(len)) + } } #[cfg(test)] @@ -99,4 +171,64 @@ mod tests { assert!(sq.at(45.0).0.distance(sq.at(5.0).0) < 1e-6); assert!(ArcPath::new(&PathData::default()).is_empty()); } + + fn polyline(points: &[Point], closed: bool) -> PathData { + PathData::single(crate::SubPath::polyline(points, closed)) + } + + /// Where `topmost` puts the middle, drawn by `xf`. + fn top_point(a: &ArcPath, xf: Affine) -> Point { + xf * a.at(a.topmost(xf).unwrap()).0 + } + + #[test] + fn topmost_points_as_drawn() { + // A circle starts at 9 o'clock and runs clockwise (y down): its top is a quarter of the way round, its + // bottom three quarters. + let circle = ArcPath::new(&shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0))); + assert!((circle.topmost(Affine::IDENTITY).unwrap() - circle.len() * 0.25).abs() < 1e-6); + assert!((circle.topmost(Affine::FLIP_Y).unwrap() - circle.len() * 0.75).abs() < 1e-6); + // As the transform draws it: turned a quarter turn the other way, 3 o'clock is at the top. + let turned = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(50.0, 50.0)); + assert!((circle.topmost(turned).unwrap() - circle.len() * 0.5).abs() < 1e-6); + // Turned part way, the top lies inside a segment. + let tilted = Affine::rotate_about(0.4, Point::new(50.0, 50.0)); + assert!(top_point(&circle, tilted).distance(Point::new(50.0, 0.0)) < 0.05, "{:?}", top_point(&circle, tilted)); + // An ellipse squashed and skewed by its transform. + let skewed = Affine::skew(0.5, 0.0) * Affine::scale_non_uniform(2.0, 0.5); + let p = top_point(&circle, skewed); + let lowest = (0..3600).map(|i| (skewed * circle.at(circle.len() * i as f64 / 3600.0).0).y).fold(f64::INFINITY, f64::min); + assert!((p.y - lowest).abs() < 1e-3, "{p:?} {lowest}"); + // A level top: the middle of the top edge, for a rectangle and a rounded rectangle. + for path in [shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), shapes::rounded_rectangle(Rect::new(10.0, 20.0, 110.0, 70.0), 8.0)] { + let a = ArcPath::new(&path); + assert!(top_point(&a, Affine::IDENTITY).distance(Point::new(60.0, 20.0)) < 1e-6, "{:?}", top_point(&a, Affine::IDENTITY)); + assert!(top_point(&a, Affine::FLIP_Y).distance(Point::new(60.0, -70.0)) < 1e-6); + } + // Far from the origin the top is still the top edge's middle. + let square = ArcPath::new(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))); + let far = Affine::translate((1e12, 1e12)); + assert!(top_point(&square, far).distance(Point::new(1e12 + 50.0, 1e12)) < 1e-3, "{:?}", top_point(&square, far)); + // A level top across the path's start: the start in the middle of the top edge. + let across = ArcPath::new(&polyline( + &[Point::new(50.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 50.0), Point::new(0.0, 50.0), Point::new(0.0, 0.0)], + true, + )); + assert!(top_point(&across, Affine::IDENTITY).distance(Point::new(50.0, 0.0)) < 1e-6); + // Two peaks: the middle of the shortest part that holds both, the dip between them. + let peaks = ArcPath::new(&polyline( + &[Point::new(0.0, 10.0), Point::new(2.0, 0.0), Point::new(4.0, 5.0), Point::new(6.0, 0.0), Point::new(8.0, 10.0)], + true, + )); + assert!(top_point(&peaks, Affine::IDENTITY).distance(Point::new(4.0, 5.0)) < 1e-6, "{:?}", top_point(&peaks, Affine::IDENTITY)); + // An open path: the middle of its topmost points too. + let open = ArcPath::new(&polyline(&[Point::new(0.0, 10.0), Point::new(5.0, 0.0), Point::new(10.0, 10.0)], false)); + assert!(top_point(&open, Affine::IDENTITY).distance(Point::new(5.0, 0.0)) < 1e-6); + // No top: a closed path that is level all round, and a path with no length. + let flat = ArcPath::new(&shapes::ellipse(Rect::new(0.0, 10.0, 100.0, 10.0))); + assert!(!flat.is_empty() && flat.topmost(Affine::IDENTITY).is_none()); + assert!(ArcPath::new(&PathData::default()).topmost(Affine::IDENTITY).is_none()); + let point = ArcPath::new(&polyline(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0)], true)); + assert!(point.topmost(Affine::IDENTITY).is_none()); + } } diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index eb038f0790..3fe0f99a92 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -158,6 +158,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { if crate::menus::enabled(app, "type.dynamicText") { items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); } + center_on_path_items(app, &mut items); } } } else if is_image { @@ -349,6 +350,18 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } } +/// The Contextual Task Bar's Center on Top and Center on Bottom, as in the Type menu, for type on a +/// closed path. +fn center_on_path_items(app: &VectorcraftApp, items: &mut Vec<(&'static str, &'static str, &'static str, serde_json::Value)>) { + for (label, icon, id) in + [(tl!("Center on Top"), "dc-al-top", "type.pathCenter.top"), (tl!("Center on Bottom"), "dc-al-bottom", "type.pathCenter.bottom")] + { + if crate::menus::enabled(app, id) { + items.push((label, icon, id, json!({}))); + } + } +} + /// The Contextual Task Bar's Image Trace button: a trace with the Default preset. const TASK_BAR_IMAGE_TRACE: &str = "taskBar.imageTrace"; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c6..d1658c0e24 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3584,6 +3584,8 @@ digits Malayalam Малаялам Type on a Path Текст по пътя Type on a Path Options Опции за текст по пътя Type on a Path Options… Опции за текст по път… + Center on Top Центриране горе + Center on Bottom Центриране долу Type on a Path Tool Инструмент „Текст по път“ Type Orientation Ориентация на текста Type stays text, and every stroke is a line with its lineweight and dashes. Текстът остава текст, а всеки щрих е линия с дебелина и чертички. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95ceaa..f8ed0e1588 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -384,6 +384,8 @@ Stair Step Stepenice Gravity Gravitacija Type on a Path Options… Opcije teksta na putanji… + Center on Top Centriraj na vrhu + Center on Bottom Centriraj na dnu Threaded Text Povezani tekst Create Kreiraj Release Selection Oslobodi odabir diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e00..a8c2f0e2a5 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2886,6 +2886,10 @@ digits Malayalam Malaiàlam Optical Margin Alignment Alineació òptica de marges Dynamic Text Text dinàmic @msg type on a path in a thread cannot use Dynamic Text: {frames} el text sobre un traçat d’un text enllaçat no pot fer servir el text dinàmic: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} el text sobre un traçat obert o compost no es pot centrar a dalt o a baix: {open} +@msg type on a path with no length or no height cannot be centered: {flat} el text sobre un traçat sense longitud o sense alçada no es pot centrar: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} el text sobre un traçat d’un text enllaçat no es pot centrar a dalt o a baix: {frames} +@msg select type on a closed path seleccioneu text sobre un traçat tancat Optimize for fast web view Optimitza per a la visualització web ràpida Optimized Optimitzat Options Opcions @@ -4626,6 +4630,8 @@ digits Malayalam Malaiàlam Type on a Path Text sobre un traçat Type on a Path Options Opcions de text sobre un traçat Type on a Path Options… Opcions de text sobre un traçat… + Center on Top Centra a dalt + Center on Bottom Centra a baix Type on a Path Tool Eina de text sobre un traçat Type Optimized Optimitzat per al text Type Orientation Orientació del text diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fae..af7e79a8ea 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -391,6 +391,8 @@ Stair Step Schody Gravity Gravitace Type on a Path Options… Možnosti textu na cestě… + Center on Top Vystředit nahoře + Center on Bottom Vystředit dole Threaded Text Zřetězený text Create Vytvořit Release Selection Uvolnit výběr @@ -408,6 +410,10 @@ Optical Margin Alignment Optické zarovnání okrajů Dynamic Text Dynamický text @msg type on a path in a thread cannot use Dynamic Text: {frames} text na cestě ve zřetězeném textu nemůže používat dynamický text: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} text na otevřené nebo složené cestě nelze vystředit nahoře ani dole: {open} +@msg type on a path with no length or no height cannot be centered: {flat} text na cestě bez délky nebo bez výšky nelze vystředit: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} text na cestě ve zřetězeném textu nelze vystředit nahoře ani dole: {frames} +@msg select type on a closed path vyberte text na uzavřené cestě Fill with Placeholder Text Vyplnit zástupným textem Insert Inline Symbol Vložit symbol do textu Show Hidden Characters Zobrazit skryté znaky diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6bf..9c0e6205f9 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -352,6 +352,8 @@ Stair Step Trappetrin Gravity Tyngdekraft Type on a Path Options… Indstillinger for tekst på kurve… + Center on Top Centrer øverst + Center on Bottom Centrer nederst Threaded Text Sammenkædet tekst Create Opret Release Selection Frigør markering diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81f..0654e68296 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2291,6 +2291,10 @@ file-filter Templates Vorlagen Optical Margin Alignment Optischer Randausgleich Dynamic Text Dynamischer Text @msg type on a path in a thread cannot use Dynamic Text: {frames} Pfadtext in verkettetem Text kann keinen dynamischen Text verwenden: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} Pfadtext auf einem offenen oder zusammengesetzten Pfad lässt sich nicht oben oder unten zentrieren: {open} +@msg type on a path with no length or no height cannot be centered: {flat} Pfadtext auf einem Pfad ohne Länge oder ohne Höhe lässt sich nicht zentrieren: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} Pfadtext in verkettetem Text lässt sich nicht oben oder unten zentrieren: {frames} +@msg select type on a closed path wählen Sie Pfadtext auf einem geschlossenen Pfad aus Optimize for fast web view Für schnelle Webanzeige optimieren Optimized Optimiert Options Optionen @@ -3618,6 +3622,8 @@ effect Points Punkte Type on a Path Pfadtext Type on a Path Options Pfadtextoptionen Type on a Path Options… Pfadtextoptionen… + Center on Top Oben zentrieren + Center on Bottom Unten zentrieren Type on a Path Tool Pfadtext-Werkzeug Type Orientation Textrichtung Type stays text, and every stroke is a line with its lineweight and dashes. Text bleibt Text, und jede Kontur ist eine Linie mit ihrer Linienstärke und ihren Strichen. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be4..7993587dac 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3053,6 +3053,10 @@ theme Appearance Εμφάνιση Optical Margin Alignment Οπτική στοίχιση περιθωρίου Dynamic Text Δυναμικό κείμενο @msg type on a path in a thread cannot use Dynamic Text: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να χρησιμοποιήσει δυναμικό κείμενο: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} κείμενο σε ανοιχτή ή σύνθετη διαδρομή δεν μπορεί να κεντραριστεί επάνω ή κάτω: {open} +@msg type on a path with no length or no height cannot be centered: {flat} κείμενο σε διαδρομή χωρίς μήκος ή χωρίς ύψος δεν μπορεί να κεντραριστεί: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να κεντραριστεί επάνω ή κάτω: {frames} +@msg select type on a closed path επιλέξτε κείμενο σε κλειστή διαδρομή Optimize for fast web view Βελτιστοποίηση για γρήγορη προβολή στον ιστό Options for Selection… Επιλογές για την επιλογή… or more spaces with a tab ή περισσότερα διαστήματα με στηλοθέτη @@ -4005,6 +4009,8 @@ digits Malayalam Μαλαγιάλαμ Type on a Path Κείμενο σε διαδρομή Type on a Path Options Επιλογές κειμένου σε διαδρομή Type on a Path Options… Επιλογές κειμένου σε διαδρομή… + Center on Top Κεντράρισμα επάνω + Center on Bottom Κεντράρισμα κάτω Type on a Path Tool Κείμενο σε διαδρομή (Type on a Path Tool) Type Orientation Προσανατολισμός κειμένου Type stays text, and every stroke is a line with its lineweight and dashes. Το κείμενο διατηρείται ως κείμενο και κάθε περίγραμμα γίνεται γραμμή με το πάχος και τις παύλες του. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae39..84f6114c87 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2337,6 +2337,10 @@ font-technology Variable Variables Optical Margin Alignment Alineación óptica de márgenes Dynamic Text Texto dinámico @msg type on a path in a thread cannot use Dynamic Text: {frames} el texto en trazado de un texto enlazado no puede usar texto dinámico: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} el texto en un trazado abierto o compuesto no se puede centrar arriba o abajo: {open} +@msg type on a path with no length or no height cannot be centered: {flat} el texto en un trazado sin longitud o sin altura no se puede centrar: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} el texto en trazado de un texto enlazado no se puede centrar arriba o abajo: {frames} +@msg select type on a closed path seleccione texto en un trazado cerrado Optimize for fast web view Optimizar para vista web rápida Optimized Optimizado Options Opciones @@ -3672,6 +3676,8 @@ digits Malayalam Malayálam Type on a Path Texto en trazado Type on a Path Options Opciones de texto en trazado Type on a Path Options… Opciones de texto en trazado… + Center on Top Centrar arriba + Center on Bottom Centrar abajo Type on a Path Tool Herramienta Texto en trazado Type Orientation Orientación del texto Type stays text, and every stroke is a line with its lineweight and dashes. El texto sigue siendo texto, y cada trazo es una línea con su grosor y guiones. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f602..3c688e2b93 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -388,6 +388,8 @@ Stair Step Porras Gravity Painovoima Type on a Path Options… Teksti polulla -asetukset… + Center on Top Keskitä ylös + Center on Bottom Keskitä alas Threaded Text Ketjutettu teksti Create Luo Release Selection Vapauta valinta @@ -405,6 +407,10 @@ Optical Margin Alignment Optinen reunustasaus Dynamic Text Dynaaminen teksti @msg type on a path in a thread cannot use Dynamic Text: {frames} ketjutetun tekstin polkuteksti ei voi käyttää dynaamista tekstiä: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} avoimella polulla tai yhdistelmäpolulla olevaa tekstiä ei voi keskittää ylös tai alas: {open} +@msg type on a path with no length or no height cannot be centered: {flat} polkutekstiä ei voi keskittää polulle, jolla ei ole pituutta tai korkeutta: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} ketjutetun tekstin polkutekstiä ei voi keskittää ylös tai alas: {frames} +@msg select type on a closed path valitse suljetulla polulla oleva teksti Fill with Placeholder Text Täytä paikkamerkkitekstillä Insert Inline Symbol Lisää tekstinsisäinen symboli Show Hidden Characters Näytä piilotetut merkit diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b4..9c48aaeb56 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2329,6 +2329,10 @@ font-technology Variable Variables Optical Margin Alignment Alignement optique des marges Dynamic Text Texte dynamique @msg type on a path in a thread cannot use Dynamic Text: {frames} le texte curviligne d’un texte lié ne peut pas utiliser le texte dynamique : {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} un texte curviligne sur un tracé ouvert ou un tracé transparent ne peut pas être centré en haut ou en bas : {open} +@msg type on a path with no length or no height cannot be centered: {flat} un texte curviligne sur un tracé sans longueur ou sans hauteur ne peut pas être centré : {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} le texte curviligne d’un texte lié ne peut pas être centré en haut ou en bas : {frames} +@msg select type on a closed path sélectionnez un texte curviligne sur un tracé fermé Optimize for fast web view Optimiser pour un affichage web rapide Optimized Optimisé Options Options @@ -3663,6 +3667,8 @@ digits Malayalam Malayalam Type on a Path Texte curviligne Type on a Path Options Options de texte curviligne Type on a Path Options… Options de texte curviligne… + Center on Top Centrer en haut + Center on Bottom Centrer en bas Type on a Path Tool Outil Texte curviligne Type Orientation Orientation du texte Type stays text, and every stroke is a line with its lineweight and dashes. Le texte reste du texte, et chaque contour devient une ligne avec son épaisseur et ses tirets. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..8f5e9e7121 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3139,6 +3139,10 @@ antiAlias Sharp शार्प Optical Margin Alignment ऑप्टिकल मार्जिन संरेखण Dynamic Text डायनैमिक टेक्स्ट @msg type on a path in a thread cannot use Dynamic Text: {frames} थ्रेडेड टेक्स्ट में पाथ पर टाइप डायनैमिक टेक्स्ट का उपयोग नहीं कर सकता: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} खुले पाथ या कंपाउंड पाथ पर टाइप को ऊपर या नीचे केंद्रित नहीं किया जा सकता: {open} +@msg type on a path with no length or no height cannot be centered: {flat} बिना लंबाई या बिना ऊँचाई वाले पाथ पर टाइप केंद्रित नहीं किया जा सकता: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} थ्रेडेड टेक्स्ट में पाथ पर टाइप को ऊपर या नीचे केंद्रित नहीं किया जा सकता: {frames} +@msg select type on a closed path बंद पाथ पर टाइप चुनें Optimize for fast web view तेजी से वेब दृश्य के लिए ऑप्टिमाइज़ Optimized ऑप्टिमाइज़्ड Options ऑप्शन्स @@ -5014,6 +5018,8 @@ antiAlias Sharp शार्प Type on a Path पाथ पर टाइप Type on a Path Options पाथ पर टाइप ऑप्शन्स Type on a Path Options… पाथ पर टाइप ऑप्शन्स… + Center on Top ऊपर केंद्रित करें + Center on Bottom नीचे केंद्रित करें Type on a Path Tool पाथ पर टाइप टूल Type Optimized टाइप ऑप्टिमाइज़ Type Orientation प्रकार अभिविन्यास diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339bc..42b92d9e7d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2824,6 +2824,10 @@ file-filter Templates Sablonok Optical Margin Alignment Optikai margóigazítás Dynamic Text Dinamikus szöveg @msg type on a path in a thread cannot use Dynamic Text: {frames} a láncolt szövegben lévő útvonalszöveg nem használhat dinamikus szöveget: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} nyitott vagy összetett útvonalon lévő útvonalszöveg nem igazítható középre felül vagy alul: {open} +@msg type on a path with no length or no height cannot be centered: {flat} a hossz vagy magasság nélküli útvonalon lévő útvonalszöveg nem igazítható középre: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} a láncolt szövegben lévő útvonalszöveg nem igazítható középre felül vagy alul: {frames} +@msg select type on a closed path jelöljön ki zárt útvonalon lévő útvonalszöveget Optimize for fast web view Optimalizálás gyors webes megtekintéshez Optimized Optimalizált Options Beállítások @@ -4382,6 +4386,8 @@ mark-style Japanese Japán Type on a Path Útvonalszöveg Type on a Path Options Útvonalszöveg beállításai Type on a Path Options… Útvonalszöveg beállításai… + Center on Top Középre igazítás felül + Center on Bottom Középre igazítás alul Type on a Path Tool Útvonalszöveg Type Optimized Szövegre optimalizált Type Orientation Szöveg iránya diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97c..f717f4b892 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -352,6 +352,8 @@ Stair Step Undakan tangga Gravity Gravitasi Type on a Path Options… Opsi teks pada jalur… + Center on Top Pusatkan di atas + Center on Bottom Pusatkan di bawah Threaded Text Teks beruntun Create Buat Release Selection Lepaskan seleksi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b58..f47795e013 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2367,6 +2367,10 @@ file-filter Templates Modelli Optical Margin Alignment Allineamento ottico dei margini Dynamic Text Testo dinamico @msg type on a path in a thread cannot use Dynamic Text: {frames} il testo su tracciato in un testo concatenato non può usare il testo dinamico: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} il testo su un tracciato aperto o composto non può essere centrato in alto o in basso: {open} +@msg type on a path with no length or no height cannot be centered: {flat} il testo su un tracciato senza lunghezza o senza altezza non può essere centrato: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} il testo su tracciato in un testo concatenato non può essere centrato in alto o in basso: {frames} +@msg select type on a closed path seleziona del testo su un tracciato chiuso Optimize for fast web view Ottimizza per visualizzazione web veloce Optimized Ottimizzato Options Opzioni @@ -3710,6 +3714,8 @@ digits Malayalam Malayalam Type on a Path Testo su tracciato Type on a Path Options Opzioni testo su tracciato Type on a Path Options… Opzioni testo su tracciato… + Center on Top Centra in alto + Center on Bottom Centra in basso Type on a Path Tool Strumento Testo su tracciato Type Orientation Orientamento testo Type stays text, and every stroke is a line with its lineweight and dashes. Il testo resta testo e ogni traccia diventa una linea con il suo spessore e i suoi tratteggi. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcadb..3910041eaf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -409,6 +409,8 @@ digits Malayalam マラヤーラム数字 Stair Step 階段 Gravity 引力 Type on a Path Options… パス上文字オプション… + Center on Top 上端に中央揃え + Center on Bottom 下端に中央揃え Threaded Text スレッドテキストオプション Create 作成 Release Selection 選択部分をリリース @@ -436,6 +438,10 @@ digits Malayalam マラヤーラム数字 Optical Margin Alignment 最適なマージン揃え Dynamic Text ダイナミックテキスト @msg type on a path in a thread cannot use Dynamic Text: {frames} スレッドテキスト内のパス上文字にはダイナミックテキストを使用できません: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} オープンパスや複合パス上のパス上文字は上端または下端に中央揃えできません: {open} +@msg type on a path with no length or no height cannot be centered: {flat} 長さや高さのないパスのパス上文字は中央揃えできません: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} スレッドテキスト内のパス上文字は上端または下端に中央揃えできません: {frames} +@msg select type on a closed path クローズパス上のパス上文字を選択してください Fill with Placeholder Text サンプルテキストの割り付け Insert Inline Symbol インラインシンボルを挿入 Show Hidden Characters 制御文字を表示 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c426..570ca353a1 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -317,6 +317,8 @@ Stair Step საფეხურები Gravity გრავიტაცია Type on a Path Options… ტრაექტორიაზე ტექსტის პარამეტრები… + Center on Top ზემოთ ცენტრში მოთავსება + Center on Bottom ქვემოთ ცენტრში მოთავსება Threaded Text დაკავშირებული ტექსტი Create შექმნა Release Selection მონიშნულის მოხსნა @@ -334,6 +336,10 @@ Optical Margin Alignment ოპტიკური არშიის სწორება Dynamic Text დინამიკური ტექსტი @msg type on a path in a thread cannot use Dynamic Text: {frames} დაკავშირებულ ტექსტში ტრაექტორიაზე ტექსტი ვერ გამოიყენებს დინამიკურ ტექსტს: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} ღია ან შედგენილ ტრაექტორიაზე ტექსტი ვერ მოთავსდება ზემოთ ან ქვემოთ ცენტრში: {open} +@msg type on a path with no length or no height cannot be centered: {flat} ტექსტი ვერ მოთავსდება ცენტრში ტრაექტორიაზე, რომელსაც არ აქვს სიგრძე ან სიმაღლე: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} დაკავშირებულ ტექსტში ტრაექტორიაზე ტექსტი ვერ მოთავსდება ზემოთ ან ქვემოთ ცენტრში: {frames} +@msg select type on a closed path მონიშნეთ დახურულ ტრაექტორიაზე ტექსტი Fill with Placeholder Text შემავსებელი ტექსტით შევსება Insert Inline Symbol ჩადგმული სიმბოლოს ჩასმა Show Hidden Characters დამალული სიმბოლოების ჩვენება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2e..3d42eb69d8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -402,6 +402,8 @@ digits Malayalam 말라얄람어 Stair Step 계단 Gravity 중력 Type on a Path Options… 패스 위 문자 옵션… + Center on Top 위쪽에 가운데 맞춤 + Center on Bottom 아래쪽에 가운데 맞춤 Threaded Text 연결된 문자 Create 만들기 Release Selection 선택 해제 @@ -429,6 +431,10 @@ digits Malayalam 말라얄람어 Optical Margin Alignment 시각적 여백 정렬 Dynamic Text 동적 텍스트 @msg type on a path in a thread cannot use Dynamic Text: {frames} 연결된 문자의 패스 위 문자에는 동적 텍스트를 사용할 수 없습니다: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} 열린 패스나 컴파운드 패스 위 문자는 위쪽이나 아래쪽에 가운데 맞춤할 수 없습니다: {open} +@msg type on a path with no length or no height cannot be centered: {flat} 길이나 높이가 없는 패스 위 문자는 가운데 맞춤할 수 없습니다: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 연결된 문자의 패스 위 문자는 위쪽이나 아래쪽에 가운데 맞춤할 수 없습니다: {frames} +@msg select type on a closed path 닫힌 패스 위 문자를 선택하세요 Fill with Placeholder Text 자리 표시 문자로 채우기 Insert Inline Symbol 인라인 심볼 삽입 Show Hidden Characters 숨겨진 문자 표시 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc0..3ef28b6895 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -366,6 +366,8 @@ Stair Step Trap Gravity Zwaartekracht Type on a Path Options… Opties voor tekst op pad… + Center on Top Boven centreren + Center on Bottom Onder centreren Threaded Text Gekoppelde tekst Create Maken Release Selection Selectie vrijgeven diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4a..600552b22b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -350,6 +350,8 @@ Stair Step Schodki Gravity Grawitacja Type on a Path Options… Opcje tekstu na ścieżce… + Center on Top Wyśrodkuj na górze + Center on Bottom Wyśrodkuj na dole Threaded Text Tekst wątkowy Create Utwórz Release Selection Uwolnij zaznaczenie @@ -367,6 +369,10 @@ Optical Margin Alignment Optyczne wyrównanie marginesów Dynamic Text Tekst dynamiczny @msg type on a path in a thread cannot use Dynamic Text: {frames} tekst na ścieżce w tekście wątkowym nie może używać tekstu dynamicznego: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} nie można wyśrodkować na górze ani na dole tekstu na ścieżce otwartej lub złożonej: {open} +@msg type on a path with no length or no height cannot be centered: {flat} nie można wyśrodkować tekstu na ścieżce bez długości lub bez wysokości: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} nie można wyśrodkować na górze ani na dole tekstu na ścieżce w tekście wątkowym: {frames} +@msg select type on a closed path zaznacz tekst na zamkniętej ścieżce Fill with Placeholder Text Wypełnij tekstem zastępczym Insert Inline Symbol Wstaw symbol w tekście Show Hidden Characters Pokaż ukryte znaki diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda2..13e9424fdd 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1933,6 +1933,10 @@ antiAlias Sharp Nítido Optical Margin Alignment Alinhamento de Margem Óptica Dynamic Text Texto Dinâmico @msg type on a path in a thread cannot use Dynamic Text: {frames} o texto em um caminho de um texto encadeado não pode usar Texto Dinâmico: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} o texto em um caminho aberto ou composto não pode ser centralizado em cima ou embaixo: {open} +@msg type on a path with no length or no height cannot be centered: {flat} o texto em um caminho sem comprimento ou sem altura não pode ser centralizado: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} o texto em um caminho de um texto encadeado não pode ser centralizado em cima ou embaixo: {frames} +@msg select type on a closed path selecione texto em um caminho fechado Optimize for fast web view Otimizar para visualização rápida na web Optimized Otimizado Options Opções @@ -3021,6 +3025,8 @@ digits Malayalam Malaiala Type becomes outlines, and strokes a CAD line can't draw become filled shapes. O texto vira contornos, e os traços que uma linha CAD não pode desenhar viram formas preenchidas. Type on a Path Texto em um Caminho Type on a Path Options… Opções de Texto em um Caminho… + Center on Top Centralizar em Cima + Center on Bottom Centralizar em Baixo Type on a Path Tool Ferramenta Texto em um Caminho Type stays text, and every stroke is a line with its lineweight and dashes. O texto permanece texto, e todo traço é uma linha com sua espessura e tracejados. Type: Tipo: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb5425823..e66f350c6a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2362,6 +2362,10 @@ file-filter Templates Шаблоны Optical Margin Alignment Оптическое выравнивание полей Dynamic Text Динамический текст @msg type on a path in a thread cannot use Dynamic Text: {frames} текст по контуру в связанном тексте не может использовать динамический текст: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} нельзя центрировать вверху или внизу текст по незамкнутому или составному контуру: {open} +@msg type on a path with no length or no height cannot be centered: {flat} нельзя центрировать текст по контуру без длины или без высоты: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} нельзя центрировать вверху или внизу текст по контуру в связанном тексте: {frames} +@msg select type on a closed path выделите текст по замкнутому контуру Optimize for fast web view Оптимизировать для быстрого просмотра в web Optimized Оптимизировано Options Параметры @@ -3704,6 +3708,8 @@ digits Malayalam Малаялам Type on a Path Текст по контуру Type on a Path Options Параметры текста по контуру Type on a Path Options… Параметры текста по контуру… + Center on Top Центрировать вверху + Center on Bottom Центрировать внизу Type on a Path Tool Инструмент «Текст по контуру» Type Orientation Ориентация текста Type stays text, and every stroke is a line with its lineweight and dashes. Текст остаётся текстом, и каждая обводка — линия с её толщиной и штрихами. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..cfdd5bef9d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2276,6 +2276,10 @@ font-technology Variable Variabla Optical Margin Alignment Optisk marginaljustering Dynamic Text Dynamisk text @msg type on a path in a thread cannot use Dynamic Text: {frames} text på bana i länkad text kan inte använda dynamisk text: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} text på en öppen eller sammansatt bana kan inte centreras överst eller nederst: {open} +@msg type on a path with no length or no height cannot be centered: {flat} text på en bana utan längd eller utan höjd kan inte centreras: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} text på bana i länkad text kan inte centreras överst eller nederst: {frames} +@msg select type on a closed path markera text på en sluten bana Optimize for fast web view Optimera för snabb webbvisning Optimized Optimerad Options Alternativ @@ -3593,6 +3597,8 @@ digits Malayalam Malayalam Type on a Path Text på bana Type on a Path Options Alternativ för text på bana Type on a Path Options… Alternativ för text på bana… + Center on Top Centrera överst + Center on Bottom Centrera nederst Type on a Path Tool Verktyg för text på bana Type Orientation Textorientering Type stays text, and every stroke is a line with its lineweight and dashes. Text förblir text, och varje kontur blir en linje med sin linjetjocklek och sina streck. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc6..606e994763 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2148,6 +2148,10 @@ shape Open Açık Optical Margin Alignment Optik Kenar Boşluğu Hizalaması Dynamic Text Dinamik Metin @msg type on a path in a thread cannot use Dynamic Text: {frames} zincirli metindeki yol üzerindeki yazı dinamik metin kullanamaz: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} açık veya bileşik bir yol üzerindeki yazı üstte veya altta ortalanamaz: {open} +@msg type on a path with no length or no height cannot be centered: {flat} uzunluğu ya da yüksekliği olmayan yol üzerindeki yazı ortalanamaz: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} zincirli metindeki yol üzerindeki yazı üstte veya altta ortalanamaz: {frames} +@msg select type on a closed path kapalı bir yol üzerindeki yazıyı seçin Optimize for fast web view Hızlı web görünümü için optimize edin Optimized Optimize edilmiş Options Seçenekler @@ -3481,6 +3485,8 @@ digits Malayalam Malayalam Type on a Path Bir Yola Yazın Type on a Path Options Yol Seçenekleri Üzerine Yazma Type on a Path Options… Yol Seçenekleri Üzerine Yazma… + Center on Top Üstte ortala + Center on Bottom Altta ortala Type on a Path Tool Yola Yazma Aracı Type Orientation Tip Yönü Type stays text, and every stroke is a line with its lineweight and dashes. Yazı metin olarak kalır ve her kontur, çizgi kalınlığı ve tireleriyle birlikte bir çizgidir. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b9..fa5cd060d6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2359,6 +2359,10 @@ file-filter Templates Шаблони Optical Margin Alignment Оптичне вирівнювання полів Dynamic Text Динамічний текст @msg type on a path in a thread cannot use Dynamic Text: {frames} текст за контуром у пов’язаному тексті не може використовувати динамічний текст: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} не можна центрувати вгорі чи внизу текст за незамкненим або складеним контуром: {open} +@msg type on a path with no length or no height cannot be centered: {flat} не можна центрувати текст за контуром без довжини або без висоти: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} не можна центрувати вгорі чи внизу текст за контуром у пов’язаному тексті: {frames} +@msg select type on a closed path виділіть текст за замкненим контуром Optimize for fast web view Оптимізувати для швидкого перегляду web Optimized Оптимізовано Options Параметри @@ -3703,6 +3707,8 @@ effect Points Точки Type on a Path Текст за контуром Type on a Path Options Параметри тексту за контуром Type on a Path Options… Параметри тексту за контуром… + Center on Top Центрувати вгорі + Center on Bottom Центрувати внизу Type on a Path Tool Інструмент «Текст за контуром» Type Orientation Орієнтація тексту Type stays text, and every stroke is a line with its lineweight and dashes. Текст залишається текстом, і кожне обведення — лінія з її товщиною та штрихами. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a62..22ccb6572a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2637,6 +2637,10 @@ digits Hindi Hindi Optical Margin Alignment Căn lề quang học Dynamic Text Văn bản động @msg type on a path in a thread cannot use Dynamic Text: {frames} chữ trên đường dẫn trong văn bản chuỗi không thể dùng văn bản động: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} không thể căn giữa ở trên hoặc ở dưới chữ trên đường dẫn mở hoặc đường dẫn phức: {open} +@msg type on a path with no length or no height cannot be centered: {flat} không thể căn giữa chữ trên đường dẫn không có độ dài hoặc không có chiều cao: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} không thể căn giữa ở trên hoặc ở dưới chữ trên đường dẫn trong văn bản chuỗi: {frames} +@msg select type on a closed path hãy chọn chữ trên một đường dẫn khép kín Optimize for fast web view Tối ưu để xem web nhanh Optimized Đã tối ưu Options Tùy chọn @@ -4079,6 +4083,8 @@ mark-style Japanese Kiểu Nhật Type on a Path Chữ trên đường dẫn Type on a Path Options Tùy chọn chữ trên đường dẫn Type on a Path Options… Tùy chọn chữ trên đường dẫn… + Center on Top Căn giữa ở trên + Center on Bottom Căn giữa ở dưới Type on a Path Tool Công cụ Chữ trên đường dẫn Type Orientation Hướng chữ Type stays text, and every stroke is a line with its lineweight and dashes. Chữ vẫn là văn bản, và mỗi nét là đường thẳng với độ dày và nét đứt. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe5..a5d29edb93 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2235,6 +2235,10 @@ file-filter Templates 模板 Optical Margin Alignment 视觉边界对齐 Dynamic Text 动态文本 @msg type on a path in a thread cannot use Dynamic Text: {frames} 串接文本中的路径文字不能使用动态文本:{frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} 开放路径或复合路径上的路径文字无法在顶部或底部居中:{open} +@msg type on a path with no length or no height cannot be centered: {flat} 路径没有长度或没有高度时,其路径文字无法居中:{flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 串接文本中的路径文字无法在顶部或底部居中:{frames} +@msg select type on a closed path 请选择闭合路径上的路径文字 Optimize for fast web view 优化快速 Web 查看 Optimized 优化 Options 选项 @@ -3538,6 +3542,8 @@ digits Malayalam 马拉雅拉姆数字 Type on a Path 路径文字 Type on a Path Options 路径文字选项 Type on a Path Options… 路径文字选项… + Center on Top 在顶部居中 + Center on Bottom 在底部居中 Type on a Path Tool 路径文字工具 Type Orientation 文字方向 Type stays text, and every stroke is a line with its lineweight and dashes. 文字维持为文字,每个描边都是带有线宽与虚线的线条。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e7708..7c9b433b5e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3307,6 +3307,10 @@ file-filter Templates 範本 Optical Margin Alignment 視覺邊界對齊 Dynamic Text 動態文字 @msg type on a path in a thread cannot use Dynamic Text: {frames} 串連文字中的路徑文字無法使用動態文字:{frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} 開放路徑或複合路徑上的路徑文字無法在頂端或底端置中:{open} +@msg type on a path with no length or no height cannot be centered: {flat} 路徑沒有長度或沒有高度時,其路徑文字無法置中:{flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 串連文字中的路徑文字無法在頂端或底端置中:{frames} +@msg select type on a closed path 請選取封閉路徑上的路徑文字 Optimize for fast web view 最佳化以便快速網頁檢視 Optimized 最佳化 Options 選項 @@ -4655,6 +4659,8 @@ digits Malayalam 馬來亞拉姆數字 Type on a Path 路徑文字 Type on a Path Options 路徑文字選項 Type on a Path Options… 路徑文字選項… + Center on Top 在頂端置中 + Center on Bottom 在底端置中 Type on a Path Tool 路徑文字工具 Type Orientation 文字方向 Type stays text, and every stroke is a line with its lineweight and dashes. 文字維持為文字,每個筆畫都是帶有線寬與虛線的線條。 diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index a449fb0361..d38e16cffd 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -351,6 +351,58 @@ fn dynamic_text_is_a_checked_type_menu_item() { assert!(!enabled(&app, id), "type on a path in a thread"); } +/// Type › Type on a Path › Center on Top and Center on Bottom run `type.pathCenter.top` and +/// `.bottom`, which the command palette also runs without params, and are enabled for type on a +/// closed path only. +#[test] +fn center_on_top_and_bottom_are_type_on_a_path_items() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let items: Vec<(&str, &str)> = menu_tree() + .into_iter() + .filter(|(menu, _)| *menu == "Type") + .flat_map(|(_, items)| items) + .filter_map(|item| match item { + Item::Sub("Type on a Path", children) => Some(children), + _ => None, + }) + .flatten() + .filter_map(|item| match item { + Item::Cmd(label, id, Value::Null) if id.starts_with("type.pathCenter") => Some((label, id)), + _ => None, + }) + .collect(); + let ids = ["type.pathCenter.top", "type.pathCenter.bottom"]; + assert_eq!(items, [("Center on Top", ids[0]), ("Center on Bottom", ids[1])]); + let palette = crate::palette::items(); + for (label, id) in items { + assert!(palette.iter().any(|(l, i, _)| l == &format!("Type › Type on a Path › {label}") && i == id), "{label}"); + } + let line = app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()["id"].clone(); + let open = app.run("text.createInPath", json!({"path": line, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [open]})).unwrap(); + assert!(ids.iter().all(|id| !enabled(&app, id)), "type on an open path"); + let square = app.run("path.create", json!({"d": "M60 200 L160 200 L160 300 L60 300 Z"})).unwrap()["id"].clone(); + let closed = app.run("text.createInPath", json!({"path": square, "mode": "onPath", "text": "Round", "at": [60, 200]})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [closed]})).unwrap(); + assert!(ids.iter().all(|id| enabled(&app, id))); + app.run("select.set", json!({"ids": [closed, open]})).unwrap(); + assert!(ids.iter().all(|id| !enabled(&app, id)), "type on a closed path and on an open one"); + // A palette pick runs the command with no params. + app.run("select.set", json!({"ids": [closed]})).unwrap(); + crate::palette::run_pick(&mut app, ids[0]); + let doc = &app.session.active().unwrap().doc; + let start = match &doc.node(vectorcraft_doc::NodeId(closed.as_u64().unwrap())).unwrap().kind { + vectorcraft_doc::NodeKind::Text(t) => match t.kind { + vectorcraft_doc::TextKind::OnPath { start, .. } => start, + ref k => panic!("{k:?}"), + }, + k => panic!("{k:?}"), + }; + // Once round, the middle of the top edge an eighth of the way round. + assert!((start - 0.625).abs() < 1e-6, "{start}"); +} + /// Type › Optical Margin Alignment menu check uses `shared_text_flag`; mixed values stay unchecked. #[test] fn optical_margin_alignment_menu_unchecked_when_shared_flag_none() { diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 0c46950f37..3a0996c819 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -396,7 +396,13 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { PATH_EFFECTS .iter() .map(|&(label, effect)| cp(label, "type.pathOptions", json!({ "effect": effect }))) - .chain([Sep, c("Type on a Path Options…", "type.pathOptions")]) + .chain([ + Sep, + c("Center on Top", "type.pathCenter.top"), + c("Center on Bottom", "type.pathCenter.bottom"), + Sep, + c("Type on a Path Options…", "type.pathOptions"), + ]) .collect(), ), c("Dynamic Text", "type.dynamicText"), diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index 53042baa69..4617f9ddeb 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -208,6 +208,34 @@ fn type_on_a_path_offers_dynamic_text() { assert!(!has(&texts, "Dynamic Text"), "{texts:?}"); } +/// Type on a closed path offers Center on Top and Center on Bottom; type on an open path does not. +#[test] +fn type_on_a_closed_path_offers_center_on_top_and_bottom() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let line = id(app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()); + let open = id(app.run("text.createInPath", json!({"path": line, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()); + select(&mut app, &[open]); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Type on a Path Options") && !has(&texts, "Center on Top") && !has(&texts, "Center on Bottom"), "{texts:?}"); + let circle = id(app.run("path.create", json!({"d": "M100 300 A100 100 0 1 1 300 300 A100 100 0 1 1 100 300 Z"})).unwrap()); + let t = id(app.run("text.createInPath", json!({"path": circle, "mode": "onPath", "text": "Round", "at": [90, 300]})).unwrap()); + select(&mut app, &[t]); + let start = |app: &VectorcraftApp| match kind(app, t) { + NodeKind::Text(t) => match t.kind { + TextKind::OnPath { start, .. } => start, + k => panic!("{k:?}"), + }, + k => panic!("{k:?}"), + }; + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Center on Top") && has(&texts, "Center on Bottom"), "{texts:?}"); + click(&mut app, "Center on Top"); + assert!((start(&app) - 0.75).abs() < 1e-3, "{}", start(&app)); + click(&mut app, "Center on Bottom"); + assert!((start(&app) - 0.25).abs() < 1e-3, "{}", start(&app)); +} + /// The path's anchors (its first subpath). fn anchors(app: &VectorcraftApp, id: u64) -> Vec { match kind(app, id) { diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..8a9f1db840 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7301,6 +7301,26 @@ begins or ends, dragging the centre bracket slides the type along its path and, {"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}} ``` +`type.pathCenter.top {ids?|id?}` and `type.pathCenter.bottom {ids?|id?}` (Type › Type on a Path › Center on Top and +Center on Bottom, and the Contextual Task Bar for type on a closed path) slide the selected type on a closed path along +it so that the middle of the span between its brackets lands on the path's topmost or bottommost point. This is +VectorCraft's own behavior. The path is measured as the object is drawn, after its transform, so a rotated, scaled or +skewed circle centers on its drawn top. On a level stretch, such as a rectangle's top edge, the middle of the stretch +is used; with several separate topmost points, the middle of the shortest part of the path that holds them all is +used. The span keeps its length, and type that runs once round stays once round. The first paragraph's alignment +becomes Center unless it is Center or Justify Center, so the type is centered there. The type is not flipped: on the +bottom of a circle, whose path runs clockwise, it reads upside down outside the circle until `type.pathOptions {flip: +true}` turns it inside. Point type and area type in the selection are left as they are. Type on an open or compound +path, type in a thread, and type on a path with no length or no height are refused with an error that names them, +and nothing changes. The menu items are enabled, and the task bar shows them, only while every selected type on a +path is on one closed path and none of it is in a thread. Several objects move in one undo step. When every object is +already centered there, nothing changes and no undo step is recorded. Each replies `{ids, start, end}` with the first +object's brackets. + +```json +{"name":"run_command","arguments":{"command":"type.pathCenter.top","params":{}}} +``` + ## Dynamic Text `type.dynamicText {on?: bool, ids?}` (Type › Dynamic Text, and the Contextual Task Bar for type on a path) turns diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..b27ef671ba 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1157,6 +1157,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency stop building a group's flat regions at the first region past the raster/vector balance's limit (10^(balance/20), rounded down) and rasterize the whole group without building the remaining regions. Building every region gives the same flattened art and the same files. With Clip Complex Regions on, a group builds every region and its image is clipped to them. New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. + Type › Type on a Path › Center on Top and Center on Bottom (`type.pathCenter.top`, `type.pathCenter.bottom`, also on the Contextual Task Bar for type on a closed path) slide type on a closed path so the middle of its span lands on the path's topmost or bottommost point as drawn, keep the span's length, and set the first paragraph to Center unless it is Center or Justify Center. This is VectorCraft's own behavior (`crates/geom/src/arc.rs`, `crates/engine/src/tests/pathtype.rs`). Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, 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 above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. From e609b26a95946f0330bc87ce3cb938ad9310767f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:55:07 -0800 Subject: [PATCH 13/79] =?UTF-8?q?Warp:=20bend=20each=20style=20as=20one=20?= =?UTF-8?q?B=C3=A9zier=20patch,=20and=20keep=20saved=20warps'=20look?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of the fifteen Warp styles now bends the bounds as one Bézier patch. The style sets the patch's control points from the bend and the size of the bounds, the horizontal distortion scales each column of control points about its middle, and the vertical distortion scales each row. The Warp effects and warp envelopes share the map (`WarpNet`). Content with no width or no height takes the middle of the patch along that side. A warp carries the map it draws with as `version`. New warps, the built-in graphic styles' Warps and warps opened from .ai files get 1. Warps in .vectorcraft documents saved before the version existed read as 0 and keep the earlier map, so those documents keep their look. Edits keep a warp's version, and Warp Options editing several objects keeps each one's own. effect.apply, effect.setParams and the warp envelope commands take `version` 0 or 1 (`1.0` counts as 1) and refuse any other value. An .ai file holds a warp's settings and not its version, so a version 0 warp saved to .ai opens again with version 1. The .ai writer compares a style's settings without the version, so saved warps still export as editable effects. Docs: docs/mcp.md (Envelopes), docs/effects-parity.md (Warp), docs/target-app-parity.md. --- crates/doc/src/hit/tests.rs | 4 +- crates/doc/src/live.rs | 198 +++++++++++- crates/doc/src/live/envelope.rs | 8 + crates/doc/src/live/mesh.rs | 13 +- crates/doc/src/live/warp.rs | 331 ++++++++++++++++++++ crates/doc/src/style_libs.rs | 28 +- crates/effects/src/catalog.rs | 2 +- crates/effects/src/lib.rs | 2 +- crates/effects/src/params.rs | 27 +- crates/effects/src/tests.rs | 51 +++ crates/effects/src/warp.rs | 35 ++- crates/engine/src/cmd/live/commands.rs | 8 +- crates/engine/src/cmd/live/envelope.rs | 17 +- crates/engine/src/cmd/params.rs | 8 + crates/engine/src/tests/effectedit.rs | 34 ++ crates/engine/src/tests/envelope.rs | 49 ++- crates/engine/src/tests/envelope_distort.rs | 7 +- crates/engine/src/tests/live.rs | 4 +- crates/eps/src/ai_editing/tests.rs | 18 ++ crates/eps/src/ai_editing/writer/styles.rs | 9 +- crates/eps/src/import/ai/envelope.rs | 1 + crates/eps/src/import/ai/read/directives.rs | 2 +- crates/eps/src/import/ai/style.rs | 1 + crates/eps/src/import/ai/style/tests.rs | 2 + crates/eps/src/import/ai/tests.rs | 14 +- crates/eps/src/import/tests_native.rs | 9 +- crates/render/src/live.rs | 4 +- crates/render/src/tests/fontchange.rs | 6 +- crates/render/src/tests/memory.rs | 4 +- crates/render/src/tests/objectfx.rs | 2 +- crates/render/src/tests/placed_document.rs | 2 +- crates/tools/src/xform/eyedropper.rs | 4 +- crates/ui-egui/src/dialogs/effect.rs | 7 + crates/ui-egui/src/dialogs/envelope.rs | 2 +- crates/ui-egui/src/dialogs/tests_warp.rs | 30 ++ crates/ui-egui/src/dialogs/warp_effect.rs | 8 +- docs/effects-parity.md | 2 +- docs/mcp.md | 14 + docs/target-app-parity.md | 1 + 39 files changed, 906 insertions(+), 62 deletions(-) diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 2c0a6984d0..3eac4202b6 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -450,7 +450,7 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() { envelope, NodeKind::Envelope { content, - kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), + kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }), fidelity: 100.0, editing: false, options: Default::default(), @@ -1192,7 +1192,7 @@ fn objects_at_edit_contents_uses_general_order() { d.alloc_id(), NodeKind::Envelope { content: vec![inner(a), inner(b)], - kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), + kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }), fidelity: 100.0, editing: true, options: Default::default(), diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 304c4e70f6..983b93419b 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -65,7 +65,7 @@ pub use expand::{ #[cfg(test)] use mesh::grid_eval; pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds}; -pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point}; +pub use warp::{WARP_NET, WarpNet, WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, uses_net, warp_point, warp_version}; #[cfg(test)] mod tests { @@ -132,6 +132,200 @@ mod tests { let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI)); assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12); } + + /// Each warp style's Bézier net, at points worked out by a separate implementation of the same + /// nets: (style, bend %, horizontal and vertical distortion %, vertical, box w × h, (u, v)) → + /// the point in the box (origin at its top left, y down). + #[test] + fn warp_nets_bend_each_style_as_its_own_patch() { + #[rustfmt::skip] + let samples = [ + ("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -31.1605665, 30.1243045), + ("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 170.241998, 58.8743443), + ("arc", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 50.6087118, 47.6275948), + ("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 18.8784581, 35.5028749), + ("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.486168, 119.348666), + ("arcLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 38.1073767, 36.0911629), + ("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 17.3830689, 17.1599587), + ("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.498463, 86.7390371), + ("arcUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 46.1081993, 36.0252855), + ("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 11.6570838), + ("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 57.3903712), + ("arch", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 49.2508226, 36.2064484), + ("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 22.6628335), + ("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 116.087703), + ("bulge", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.965576, 36.2064484), + ("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 10.6489723, 25.9914004), + ("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 208.565464, 99.4817585), + ("shellLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 41.043987, 36.1302767), + ("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -21.8346746, 41.6042708), + ("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.791499, 90.3680631), + ("shellUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.9282896, 36.1475509), + ("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 55.92), + ("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 55.44), + ("flag", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 58.098, 35.91), + ("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 40.8864), + ("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 83.7792), + ("wave", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.33, 35.91), + ("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 19.632), + ("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 62.352), + ("fish", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 44.994, 35.91), + ("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 146.64), + ("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 102.48), + ("rise", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 118.686, 35.91), + ("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 9.1136, 27.2784), + ("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.2208, 94.1472), + ("fisheye", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 47.514, 35.7048), + ("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -0.16, 27.84), + ("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.48, 97.68), + ("inflate", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.54), + ("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 40.16, 27.84), + ("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 153.52, 97.68), + ("squeeze", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.0), + ("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 32.2472, 27.9588), + ("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 149.1136, 89.2224), + ("twist", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.56325, 33.8769), + ]; + for (style, bend, dh, dv, vertical, w, h, u, v, x, y) in samples { + let Some(st) = WarpStyle::from_id(style) else { panic!("{style}") }; + let p = WarpNet::new(st, bend / 100.0, dh / 100.0, dv / 100.0, w, h, vertical).at(u, v); + assert!(p.distance(Point::new(x, y)) < 1e-6, "{style} {bend} {dh} {dv} {vertical} ({u}, {v}): {p:?} vs ({x}, {y})"); + } + } + + /// Arc's edges are concentric circles: at 100 % on a 200 × 100 box the bottom edge is the half + /// circle through the bottom corners about the bottom's middle, and the top edge the one 100 + /// further out. One cubic for a half circle strays up to 1.84 % of the radius from the circle. + #[test] + fn warp_net_arc_edges_are_concentric_circles() { + let net = WarpNet::new(WarpStyle::Arc, 1.0, 0.0, 0.0, 200.0, 100.0, false); + let center = Point::new(100.0, 100.0); + for i in 0..=20 { + let u = f64::from(i) / 20.0; + for (v, r) in [(0.0, 200.0), (1.0, 100.0)] { + let d = net.at(u, v).distance(center); + assert!((d - r).abs() < 0.0184 * r, "u {u}, v {v}: {d} from the center, not {r}"); + // Its ends and middle lie on the circle. + if i % 10 == 0 { + assert!((d - r).abs() < 1e-9, "u {u}, v {v}: {d} from the center, not {r}"); + } + } + } + // Bend 0 leaves the box as it is. + for st in [WarpStyle::Arc, WarpStyle::ShellLower, WarpStyle::Bulge, WarpStyle::Twist] { + let flat = WarpNet::new(st, 0.0, 0.0, 0.0, 200.0, 100.0, false); + assert!(flat.at(0.3, 0.7).distance(Point::new(60.0, 70.0)) < 1e-9, "{st:?}"); + } + } + + /// The horizontal distortion scales each column of the net about its middle (narrowing the left + /// side for a positive value), then the vertical one each row (narrowing the top). + #[test] + fn warp_net_distortion_scales_the_columns_then_the_rows() { + let corners = |dh: f64, dv: f64| { + let net = WarpNet::new(WarpStyle::Arc, 0.0, dh, dv, 200.0, 100.0, false); + [net.at(0.0, 0.0), net.at(1.0, 0.0), net.at(0.0, 1.0), net.at(1.0, 1.0)] + }; + let near = |got: [Point; 4], want: [(f64, f64); 4]| got.iter().zip(want).all(|(p, (x, y))| p.distance(Point::new(x, y)) < 1e-9); + assert!(near(corners(0.5, 0.0), [(0.0, 25.0), (200.0, -25.0), (0.0, 75.0), (200.0, 125.0)]), "{:?}", corners(0.5, 0.0)); + assert!(near(corners(0.0, 0.5), [(50.0, 0.0), (150.0, 0.0), (-50.0, 100.0), (250.0, 100.0)]), "{:?}", corners(0.0, 0.5)); + // Both coordinates scale: after the columns the top row runs from (0, 15) to (200, -15), and + // it then scales by 1.4 about its middle, (100, 0). + assert!(near(corners(0.3, -0.4), [(-40.0, 21.0), (240.0, -21.0), (40.0, 91.0), (160.0, 109.0)]), "{:?}", corners(0.3, -0.4)); + } + + /// Numbers no warp can take still give a finite net: a bend or distortion that isn't a number + /// counts as 0, and out of range as its limit; a size that isn't a number as 0. + #[test] + fn warp_net_takes_any_numbers() { + for st in [WarpStyle::Arc, WarpStyle::ShellUpper, WarpStyle::Wave, WarpStyle::Inflate, WarpStyle::Twist] { + for (b, dh, w, h) in + [(f64::NAN, 0.2, 10.0, 10.0), (5.0, f64::INFINITY, 10.0, 0.0), (-1e300, -3.0, f64::NAN, 1e-300), (1e-300, 0.0, 0.0, 0.0)] + { + for vertical in [false, true] { + let p = WarpNet::new(st, b, dh, dh, w, h, vertical).at(0.3, 0.6); + assert!(p.x.is_finite() && p.y.is_finite(), "{st:?} {b} {dh} {w} {h}: {p:?}"); + } + } + } + let clamped = WarpNet::new(WarpStyle::Flag, 7.0, 0.0, 0.0, 200.0, 100.0, false); + assert_eq!(clamped, WarpNet::new(WarpStyle::Flag, 1.0, 0.0, 0.0, 200.0, 100.0, false)); + } + + /// A box near the largest finite size still warps: Flag on a 1e308 × 100 box moves points only + /// up and down, as on a 1 × 1e-306 one scaled up, and on a 1e308 × 1e-300 box keeps the box's + /// height. Fisheye at 100 % on a 1.7e308 square, whose net goes past the largest finite number, + /// gives finite points inside the box and past it, and the unit square's points scaled up where + /// those are finite. Fisheye moves y by the height alone, so on boxes of the largest finite + /// width and a height of 1e-300 or 100 every y is the unit square's times the height, also + /// where x goes past the largest finite number. + #[test] + fn warp_net_bends_a_huge_box_without_overflowing() { + let w = 1e308; + let flag = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 100.0, false); + let small = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, 1.0, 100.0 / w, false); + for (u, v) in [(0.0, 0.0), (0.3, 0.6), (0.9, 1.0), (1.0, 0.5)] { + let (p, q) = (flag.at(u, v), small.at(u, v)); + assert!(p.is_finite(), "({u}, {v}): {p:?}"); + assert!((p.x - u * w).abs() <= 1e-12 * w, "({u}, {v}): {p:?}"); + assert!((p.y - q.y * w).abs() <= 1e-9 * p.y.abs().max(1.0), "({u}, {v}): {p:?} against {q:?}"); + } + let thin = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 1e-300, false); + assert_eq!(thin.at(0.0, 1.0), Point::new(0.0, 1e-300)); + let fisheye = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.7e308, 1.7e308, false); + for (u, v) in [(0.1, 0.1), (0.8, 0.3), (1.5, 0.5), (3.0, 3.0)] { + let p = fisheye.at(u, v); + assert!(p.is_finite(), "({u}, {v}): {p:?}"); + } + let unit = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.0, 1.0, false); + let near = |a: f64, b: f64| (a - b).abs() <= 1e-12 * b.abs().max(f64::MIN_POSITIVE); + let (p, q) = (fisheye.at(0.1, 0.1), unit.at(0.1, 0.1)); + assert!(near(p.x, q.x * 1.7e308) && near(p.y, q.y * 1.7e308), "{p:?} against {q:?}"); + // The bottom of a box with a subnormal height stays exactly at that height: the net is + // built on the box halved, and the height's lowest bit but one is set. + let low = f64::MIN_POSITIVE / 4.0 + f64::from_bits(2); + let sub = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, low, false).at(0.0, 1.0); + assert_eq!(sub.y, low, "{sub:?}"); + for h in [1e-300, 100.0] { + let tall = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, h, false); + for (u, v) in [(0.1, 0.1), (0.9, 0.4), (0.5, 0.8)] { + let (p, q) = (tall.at(u, v), unit.at(u, v)); + assert!(p.is_finite() && near(p.y, q.y * h), "h {h}, ({u}, {v}): {p:?} against {q:?}"); + } + } + } + + /// Content with no width or no height takes the middle of a warp envelope's patch along that + /// side: under Arc a vertical line stays vertical, under Bulge a horizontal line stays straight. + #[test] + fn warp_envelopes_bend_flat_content_as_the_middle_of_the_patch() { + let warp = |style: &str| EnvelopeKind::Warp { style: style.into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; + let arc = warp("arc"); + let map = envelope_mapper(&arc, Rect::new(100.0, 0.0, 100.0, 100.0)); + for y in [0.0, 30.0, 100.0] { + let p = map(Point::new(100.0, y)); + assert!((p.x - 100.0).abs() <= 1e-9, "{y}: {p:?}"); + } + let bulge = warp("bulge"); + let map = envelope_mapper(&bulge, Rect::new(0.0, 50.0, 200.0, 50.0)); + for x in [0.0, 70.0, 200.0] { + let p = map(Point::new(x, 50.0)); + assert!((p.y - 50.0).abs() <= 1e-9, "{x}: {p:?}"); + } + } + + /// A warp envelope's map version: none in a file reads as 0 (the earlier map), 0 is written + /// as none, so files keep their bytes, and the nets' version is written. + #[test] + fn warp_envelopes_write_their_map_version_only_when_it_has_one() { + let old: EnvelopeKind = serde_json::from_str(r#"{"type":"warp","style":"arc","bend":20}"#).unwrap(); + assert!(matches!(old, EnvelopeKind::Warp { version: 0, .. }), "{old:?}"); + assert!(!serde_json::to_string(&old).unwrap().contains("version")); + let new = EnvelopeKind::Warp { style: "arc".into(), bend: 20.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; + let text = serde_json::to_string(&new).unwrap(); + assert!(text.contains(r#""version":1"#), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap(), new); + } use vectorcraft_geom::shapes; fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc { @@ -537,7 +731,7 @@ mod tests { #[test] fn an_envelope_maps_from_its_box_with_its_margins() { let rect = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), crate::Appearance::default_art()); - let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let content = [Arc::new(rect)]; let src = |content: &[Arc], frame: Affine, options: EnvelopeOptions| EnvelopeMap::new(content, &kind, frame, options).unwrap().src(); // Top, bottom, left and right. diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 8abe961841..f197837020 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -3,6 +3,10 @@ use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of}; use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes}; use vectorcraft_geom::Homography; +fn is_zero(n: &u32) -> bool { + *n == 0 +} + /// How an envelope distorts its content. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] @@ -19,6 +23,10 @@ pub enum EnvelopeKind { v: f64, #[serde(default = "yes")] horizontal: bool, + /// The map the warp draws with: [`WARP_NET`](super::WARP_NET) for its style's Bézier net, + /// 0 (as in files from before the version existed) for [`warp_point`](super::warp_point). + #[serde(default, skip_serializing_if = "is_zero")] + version: u32, }, /// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto. Mesh { diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index c59374dee9..be865e2769 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -1,5 +1,5 @@ use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at}; -use super::warp::{WarpStyle, flatten_subpath, resample, warp_point}; +use super::warp::{WarpNet, WarpStyle, flatten_subpath, resample, uses_net, warp_point}; use super::{ Affine, Anchor, Arc, Color, CubicBez, Deserialize, Node, ParamCurve, PathData, Point, Rect, Serialize, Shape, SubPath, Vec2, fifty, lerp_color, one, @@ -1731,10 +1731,19 @@ pub(super) fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box Point + 'a> { let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height()); match kind { - EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => { + EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal, version } => { let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc); let b = bend.clamp(-100.0, 100.0) / 100.0; let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0); + if uses_net(*version) { + let net = WarpNet::new(st, b, dh, dv, src.width(), src.height(), !*horizontal); + // Content with no width or no height takes the middle of the patch along that side. + let (flat_u, flat_v) = (src.width() <= 0.0, src.height() <= 0.0); + return Box::new(move |u: f64, v: f64| { + let (u, v) = (if flat_u { 0.5 } else { u }, if flat_v { 0.5 } else { v }); + frame * (src.origin() + net.at(u, v).to_vec2()) + }); + } let c = src.center(); let (hw, hh) = (src.width() / 2.0, src.height() / 2.0); let horizontal = *horizontal; diff --git a/crates/doc/src/live/warp.rs b/crates/doc/src/live/warp.rs index 6d7392ac5f..08bef91140 100644 --- a/crates/doc/src/live/warp.rs +++ b/crates/doc/src/live/warp.rs @@ -109,6 +109,337 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f6 (x2, y2) } +// ===================================================================================== +// Warp nets: each style as one Bézier patch over its box +// ===================================================================================== + +/// The `version` a Warp effect's params or a warp envelope carries for the [`WarpNet`] map. With +/// none (or 0) a warp draws with [`warp_point`], as every warp did before, so saved documents keep +/// their look; new and imported warps are given this one. +pub const WARP_NET: u32 = 1; + +/// Does a warp of map `version` draw with [`WarpNet`]? A version newer than this build knows draws +/// with the newest map it has. +pub fn uses_net(version: u32) -> bool { + version >= WARP_NET +} + +/// The map version `v` names when it is one this build draws: a whole number from 0 to +/// [`WARP_NET`], written as an integer or as a float (`1` or `1.0`). +pub fn warp_version(v: &serde_json::Value) -> Option { + let n = v.as_f64()?; + (n.fract() == 0.0 && (0.0..=f64::from(WARP_NET)).contains(&n)).then_some(n as u32) +} + +/// A style's own control net: `rows` × `cols` points (2 to 4 each) of a Bézier patch, row by row +/// from the top, in the box's coordinates (origin at its top left, y down). +#[derive(Clone, Copy, Debug)] +struct Net { + rows: usize, + cols: usize, + p: [[Point; 4]; 4], +} + +impl Net { + /// The undistorted box `w` × `h` as a `rows` × `cols` net. + fn flat(w: f64, h: f64, rows: usize, cols: usize) -> Self { + let mut p = [[Point::ZERO; 4]; 4]; + for (r, row) in p.iter_mut().enumerate().take(rows) { + for (c, q) in row.iter_mut().enumerate().take(cols) { + *q = Point::new(w * (c as f64 / (cols - 1) as f64), h * (r as f64 / (rows - 1) as f64)); + } + } + Self { rows, cols, p } + } + + /// Upside down within a box `h` high: the rows in reverse order, each point mirrored. + fn mirrored(self, h: f64) -> Self { + let mut out = self; + for (to, from) in out.p[..self.rows].iter_mut().zip(self.p[..self.rows].iter().rev()) { + for (q, f) in to.iter_mut().zip(from).take(self.cols) { + *q = Point::new(f.x, h - f.y); + } + } + out + } + + /// Rows and columns exchanged, and x with y: a net made on the box turned on its side. + fn transposed(self) -> Self { + let mut out = Self { rows: self.cols, cols: self.rows, p: [[Point::ZERO; 4]; 4] }; + for (r, row) in self.p[..self.rows].iter().enumerate() { + for (c, q) in row[..self.cols].iter().enumerate() { + out.p[c][r] = Point::new(q.y, q.x); + } + } + out + } + + /// Each column scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from + /// 0 at the left column to 1 at the right: a horizontal distortion `a` (-1..1). + fn distort_columns(&mut self, a: f64) { + let (rows, cols) = (self.rows, self.cols); + for c in 0..cols { + let s = 1.0 + a * (2.0 * c as f64 / (cols - 1) as f64 - 1.0); + let mid = half_way(self.p[0][c], self.p[rows - 1][c]); + for row in &mut self.p[..rows] { + row[c] = mid + (row[c] - mid) * s; + } + } + } + + /// Each row scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from 0 + /// at the top row to 1 at the bottom: a vertical distortion `a` (-1..1). + fn distort_rows(&mut self, a: f64) { + let (rows, cols) = (self.rows, self.cols); + for (r, row) in self.p[..rows].iter_mut().enumerate() { + let s = 1.0 + a * (2.0 * r as f64 / (rows - 1) as f64 - 1.0); + let mid = half_way(row[0], row[cols - 1]); + for q in &mut row[..cols] { + *q = mid + (*q - mid) * s; + } + } + } + + /// The same patch as a bicubic net (degree elevation, which leaves the surface as it is). + fn bicubic(self) -> [[Point; 4]; 4] { + let rows: Vec<[Point; 4]> = self.p[..self.rows].iter().map(|row| cubic_line(&row[..self.cols])).collect(); + let mut out = [[Point::ZERO; 4]; 4]; + for c in 0..4 { + let col: Vec = rows.iter().map(|row| row[c]).collect(); + for (to, q) in out.iter_mut().zip(cubic_line(&col)) { + to[c] = q; + } + } + out + } +} + +/// The point half way from `a` to `b`, as halves added: two finite points always give a finite one. +fn half_way(a: Point, b: Point) -> Point { + Point::new(0.5 * a.x + 0.5 * b.x, 0.5 * a.y + 0.5 * b.y) +} + +/// A Bézier curve of 2 to 4 control points as the 4 of the same curve's cubic form. +fn cubic_line(p: &[Point]) -> [Point; 4] { + match *p { + [a, b] => [a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b], + [a, b, c] => [a, a.lerp(b, 2.0 / 3.0), c.lerp(b, 2.0 / 3.0), c], + [a, b, c, d] => [a, b, c, d], + _ => [Point::ZERO; 4], + } +} + +/// The handle offsets (along the chord, across it) of a cubic circular arc over a chord `chord` +/// long that turns through θ = |`b`| × 180°: each handle is 4/3 tan(θ/4) of the radius long, along +/// the arc's end tangent. With the radius `chord / (2 sin(θ/2))` that length is +/// `chord / (3 cos²(θ/4))`, which a bend near 0 leaves at a third of the chord. +fn arc_handles(chord: f64, b: f64) -> (f64, f64) { + let th = b.abs().min(1.0) * PI; + let len = chord / (3.0 * (th / 4.0).cos().powi(2)); + (len * (th / 2.0).cos(), len * (th / 2.0).sin()) +} + +/// A row from (0, `y`) to (`w`, `y`) bent into a circular arc of |`b`| × 180°: up for a positive +/// `b` when `up`, down when not, the other way for a negative one. +fn arc_row(w: f64, y: f64, b: f64, up: bool) -> [Point; 4] { + let (ax, ay) = arc_handles(w, b); + let dy = if (b > 0.0) == up { -ay } else { ay }; + [Point::new(0.0, y), Point::new(ax, y + dy), Point::new(w - ax, y + dy), Point::new(w, y)] +} + +/// Arc at bend |`b`| as four rows: the bottom edge bends up through the bottom corners, the top +/// edge is the arc around the same center `h` further out, and the rows between are spaced evenly +/// along the radii. The top corners lie `h` out from the bottom ones along the radii, which lean +/// out by θ/2. +fn arc_rows(w: f64, h: f64, b: f64) -> [[Point; 4]; 4] { + let th = b.abs().min(1.0) * PI; + let (s, c) = (th / 2.0).sin_cos(); + let (tl, tr) = (Point::new(-h * s, h * (1.0 - c)), Point::new(w + h * s, h * (1.0 - c))); + let (ax, ay) = arc_handles(tr.x - tl.x, b); + let top = [tl, Point::new(tl.x + ax, tl.y - ay), Point::new(tr.x - ax, tr.y - ay), tr]; + let bottom = arc_row(w, h, b.abs(), true); + let mut out = [[Point::ZERO; 4]; 4]; + for (r, row) in out.iter_mut().enumerate() { + for (q, (t, u)) in row.iter_mut().zip(top.iter().zip(bottom)) { + *q = t.lerp(u, r as f64 / 3.0); + } + } + out +} + +/// Warp style `style`'s own net at bend `b` (-1..1) over the box `w` × `h`, before distortion. +/// `vertical`: the net of the box turned on its side, where Rise moves the other half. +fn style_net(style: WarpStyle, b: f64, w: f64, h: f64, vertical: bool) -> Net { + use WarpStyle::*; + let two = |top: [Point; 4], bottom: [Point; 4]| { + let mut p = [[Point::ZERO; 4]; 4]; + (p[0], p[1]) = (top, bottom); + Net { rows: 2, cols: 4, p } + }; + let flat_row = |y: f64| [0.0, 1.0, 2.0, 3.0].map(|c| Point::new(w * c / 3.0, y)); + match style { + Arc => { + let rows = arc_rows(w, h, b); + let net = two(rows[0], rows[3]); + if b < 0.0 { net.mirrored(h) } else { net } + } + ArcUpper => two(arc_row(w, 0.0, b, true), flat_row(h)), + ArcLower => two(flat_row(0.0), arc_row(w, h, b, false)), + Arch => { + let top = arc_row(w, 0.0, b, true); + two(top, top.map(|q| Point::new(q.x, q.y + h))) + } + Bulge => two(arc_row(w, 0.0, b, true), arc_row(w, h, b, false)), + ShellLower | ShellUpper => { + let a = arc_rows(w, h, b); + let mut net = Net::flat(w, h, 4, 4); + if b >= 0.0 { + // The outer arc and the ends of the row inside it, upside down for Shell Lower. + net.p[0] = a[0]; + (net.p[1][0], net.p[1][3]) = (a[1][0], a[1][3]); + if style == ShellLower { net.mirrored(h) } else { net } + } else { + // The inner arc and the ends of the row outside it, upside down for Shell Upper. + net.p[3] = a[3]; + (net.p[2][0], net.p[2][3]) = (a[2][0], a[2][3]); + if style == ShellUpper { net.mirrored(h) } else { net } + } + } + Flag | Fish | Rise => { + let mut net = Net::flat(w, h, 2, 4); + let d = 2.0 * b * h; + match style { + Flag => { + for row in &mut net.p[..2] { + (row[1].y, row[2].y) = (row[1].y + d, row[2].y - d); + } + } + Fish => { + (net.p[0][1].y, net.p[0][2].y) = (net.p[0][1].y - d, net.p[0][2].y + d); + (net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d); + } + _ => { + for row in &mut net.p[..2] { + if vertical { + (row[2].y, row[3].y) = (row[2].y - d, row[3].y - d); + } else { + (row[0].y, row[1].y) = (row[0].y + d, row[1].y + d); + } + } + } + } + net + } + Wave => { + let mut net = Net::flat(w, h, 3, 4); + let d = 2.0 * b * h; + (net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d); + net + } + Fisheye => { + let mut net = Net::flat(w, h, 4, 4); + let c = Point::new(w / 2.0, h / 2.0); + for row in &mut net.p[1..3] { + for q in &mut row[1..3] { + *q = c + (*q - c) * (1.0 + 4.0 * b); + } + } + net + } + Inflate | Squeeze => { + let mut net = Net::flat(w, h, 3, 3); + let side = if style == Inflate { b * w / 2.0 } else { -b * w / 2.0 }; + net.p[0][1].y -= b * h / 2.0; + net.p[2][1].y += b * h / 2.0; + net.p[1][0].x -= side; + net.p[1][2].x += side; + net + } + Twist => { + // The four inner points move round: clockwise for a positive bend. + let mut net = Net::flat(w, h, 4, 4); + let (dx, dy) = (b.abs() * w, b.abs() * h); + if b >= 0.0 { + net.p[1][1].x += dx; + net.p[1][2].y += dy; + net.p[2][2].x -= dx; + net.p[2][1].y -= dy; + } else { + net.p[1][1].y += dy; + net.p[1][2].x -= dx; + net.p[2][2].y -= dy; + net.p[2][1].x += dx; + } + net + } + } +} + +/// A warp as one bicubic Bézier patch over its box: the style's own net at the bend, its columns +/// scaled by the horizontal distortion and then its rows by the vertical one. Points are in the +/// box's coordinates (origin at its top left, y down). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WarpNet { + /// The control points over the box divided by `scale`. + net: [[Point; 4]; 4], + /// 1, or a power of two when the net over the box itself does not fit in finite numbers. + scale: f64, + w: f64, + h: f64, +} + +impl WarpNet { + /// The net of `style` over a box `w` × `h` at bend `b` and distortions `dh` and `dv` (each + /// -1..1, clamped); `vertical` bends along the box's height. + pub fn new(style: WarpStyle, b: f64, dh: f64, dv: f64, w: f64, h: f64, vertical: bool) -> Self { + let unit = |x: f64| if x.is_finite() { x.clamp(-1.0, 1.0) } else { 0.0 }; + let size = |x: f64| if x.is_finite() { x.max(0.0) } else { 0.0 }; + let (b, dh, dv, w, h) = (unit(b), unit(dh), unit(dv), size(w), size(h)); + let build = |w: f64, h: f64| { + let mut net = if vertical { style_net(style, b, h, w, true).transposed() } else { style_net(style, b, w, h, false) }; + net.distort_columns(dh); + net.distort_rows(dv); + net.bicubic() + }; + // A box near the largest finite size can overflow the net. The net is then built on the box + // divided by the smallest power of two that fits (2 to 4096), an exact division for every + // side above the subnormal range, and points are multiplied back as they are read. + let mut out = Self { net: build(w, h), scale: 1.0, w, h }; + while out.scale < 4096.0 && !out.net.iter().flatten().all(|q| q.is_finite()) { + let scale = out.scale * 2.0; + out = Self { net: build(w / scale, h / scale), scale, w, h }; + } + out + } + + /// The point at `(u, v)` of the box (0..1 across and down; outside, the patch goes on). A + /// coordinate the patch would send past the largest finite number is the box's own, held to + /// the finite range. + pub fn at(&self, u: f64, v: f64) -> Point { + let p = self.at_scaled(u, v); + let held = |x: f64| if x.is_nan() { 0.0 } else { x.clamp(-f64::MAX, f64::MAX) }; + let coord = |x: f64, flat: f64| if (x * self.scale).is_finite() { x * self.scale } else { held(flat) }; + Point::new(coord(p.x, u * self.w), coord(p.y, v * self.h)) + } + + /// [`Self::at`] divided by the net's scale. + fn at_scaled(&self, u: f64, v: f64) -> Point { + let bern = |t: f64| { + let s = 1.0 - t; + [s * s * s, 3.0 * t * s * s, 3.0 * t * t * s, t * t * t] + }; + let (bu, bv) = (bern(u), bern(v)); + let (mut x, mut y) = (0.0, 0.0); + for (row, wv) in self.net.iter().zip(bv) { + for (q, wu) in row.iter().zip(bu) { + x += wv * wu * q.x; + y += wv * wu * q.y; + } + } + Point::new(x, y) + } +} + /// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them. fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez { let c = sp.segment(i); diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index f64bdb4300..5b90427162 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -192,8 +192,14 @@ fn round(h: &str, width: f64) -> StrokeLayer { StrokeLayer { cap: LineCap::Round, join: LineJoin::Round, ..stroke(h, width) } } -/// Live effect `id` with `params` (the others keep the effect's defaults). -fn fx(id: &str, params: Value) -> Effect { +/// Live effect `id` with `params` (the others keep the effect's defaults). A Warp draws with its +/// style's Bézier net, as a new Warp does. +fn fx(id: &str, mut params: Value) -> Effect { + if id.starts_with("warp.") + && let Value::Object(m) = &mut params + { + m.entry("version").or_insert(json!(crate::live::WARP_NET)); + } Effect { id: id.into(), params, visible: true } } @@ -552,6 +558,24 @@ mod tests { assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); } + /// The built-in styles' Warps draw with the map a new Warp takes. + #[test] + fn builtin_warp_styles_take_the_nets_map() { + let mut warps = 0; + for b in STYLE_LIBRARIES { + let lib = builtin_style_library(b.id).unwrap(); + for g in &lib.styles { + let mut effects: Vec<&Effect> = g.appearance.effects.iter().collect(); + effects.extend(g.appearance.items.iter().flat_map(|i| i.effects())); + for e in effects.into_iter().filter(|e| e.id.starts_with("warp.")) { + assert_eq!(e.params["version"], json!(crate::live::WARP_NET), "{} › {}: {}", b.id, g.name, e.id); + warps += 1; + } + } + } + assert!(warps >= 3, "{warps}"); + } + #[test] fn vcstyles_round_trip() { let mut lib = (*builtin_style_library("gradient-finishes").unwrap()).clone(); diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 5c18f2d5f0..a827bea0e2 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -112,7 +112,7 @@ fn lengths_of(id: &str) -> Lengths { } } -const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; +const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}"; const DT: &[&str] = &["Effect", "Distort & Transform"]; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 67b4b464fd..6165aa9a9b 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -94,7 +94,7 @@ pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; pub use trap::TRAP; pub use vectorcraft_three_d::Mesh; -pub use warp::{WarpStyle, warp_point}; +pub use warp::{WARP_NET, WarpNet, WarpStyle, map_version, warp_point, warp_version}; mod catalog; mod geometry; diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index 7785aff335..1e3c8c1d31 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -31,7 +31,8 @@ pub fn merged_params(id: &str, params: &Value) -> Value { /// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its /// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of /// then) become the current ones that show exactly the same, unless those are given too, which win. -/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°). +/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°), and a Warp's +/// map `version` given as a whole float is stored as an integer. pub fn upgrade_params(id: &str, params: &Value) -> Value { match params { Value::Object(m) => { @@ -39,6 +40,7 @@ pub fn upgrade_params(id: &str, params: &Value) -> Value { upgrade_perspective(id, &mut m); upgrade_light(id, &mut m); upgrade_scribble(id, &mut m); + upgrade_warp_version(id, &mut m); Value::Object(m) } other => other.clone(), @@ -71,6 +73,19 @@ fn upgrade_perspective(id: &str, m: &mut Map) { } } +/// A Warp's map `version` given as a whole float (`1.0`) is stored as the integer. +fn upgrade_warp_version(id: &str, m: &mut Map) { + if !id.starts_with("warp.") { + return; + } + if let Some(v) = m.get_mut("version") + && !v.is_u64() + && let Some(n) = crate::warp::warp_version(v) + { + *v = json!(n); + } +} + fn upgrade_scribble(id: &str, m: &mut Map) { if id != "stylize.scribble" { return; @@ -91,7 +106,15 @@ pub fn new_effect(id: &str, params: &Value) -> Option { if !catalog_index().contains_key(id) { vectorcraft_plugins::effect::installed(id)?; } - Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true }) + let mut params = merged_params(id, params); + // A new Warp takes its style's Bézier net unless a map is given; the catalog's defaults carry + // no version, so warps saved without one keep the earlier map. + if id.starts_with("warp.") + && let Value::Object(m) = &mut params + { + m.entry("version").or_insert(json!(crate::warp::WARP_NET)); + } + Some(Effect { id: id.to_string(), params, visible: true }) } /// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 532251bd73..adc518043e 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -209,6 +209,57 @@ fn warp_arc_bends_the_top_edge() { assert!(b.x0 < -1.0 || b.x1 > 101.0, "{b:?}"); } +/// A Warp of `version` 1 bends its bounds as its style's Bézier net; one without a version, as +/// saved before the version existed, draws as it always did. Flag at 60 % on a 200 × 100 box: the +/// net moves the top edge up to 6 × 0.6 × 100 × max u(1 - u)(1 - 2u) = 34.64 out, the earlier map +/// moves it 0.6 × 25 = 15. +#[test] +fn a_warp_draws_with_the_map_its_version_names() { + let rect = shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 120.0)); + // u(1 - u)(1 - 2u) peaks at √3 / 18. + let peak = 360.0 * 3f64.sqrt() / 18.0; + for (version, out) in [(json!(1), peak), (json!(null), 15.0), (json!(0), 15.0)] { + let b = run("warp.flag", json!({"bend": 60, "version": version}), &rect).bounds().unwrap(); + assert!(close(b.y0, 20.0 - out, 0.1) && close(b.y1, 120.0 + out, 0.1), "version {version}: {b:?}"); + assert!(close(b.x0, 10.0, 1e-9) && close(b.x1, 210.0, 1e-9), "version {version}: {b:?}"); + } +} + +/// A straight line has a box with no width or no height, and the net bends it as the middle of +/// the patch along that side: under Arc a vertical line stays vertical, and under Bulge, Fisheye, +/// Inflate and Squeeze a horizontal line stays straight. +#[test] +fn a_warp_bends_a_flat_box_as_the_middle_of_its_patch() { + let anchors = |p: &PathData| p.subpaths.iter().flat_map(|s| s.anchors.clone()).collect::>(); + let vertical = shapes::line(Point::new(100.0, 0.0), Point::new(100.0, 100.0)); + let out = run("warp.arc", json!({"bend": 50, "version": 1}), &vertical); + assert!(anchors(&out).iter().all(|a| close(a.p.x, 100.0, 1e-9) && close(a.h_in.x, 100.0, 1e-9) && close(a.h_out.x, 100.0, 1e-9)), "{out:?}"); + let horizontal = shapes::line(Point::new(0.0, 50.0), Point::new(200.0, 50.0)); + for style in ["bulge", "fisheye", "inflate", "squeeze"] { + let out = run(&format!("warp.{style}"), json!({"bend": 50, "version": 1}), &horizontal); + assert!( + anchors(&out).iter().all(|a| close(a.p.y, 50.0, 1e-9) && close(a.h_in.y, 50.0, 1e-9) && close(a.h_out.y, 50.0, 1e-9)), + "{style}: {out:?}" + ); + } +} + +/// A new Warp takes the nets' version unless one is given, and the catalog's defaults carry none, +/// so a saved warp's params don't gain one. +#[test] +fn new_warps_take_the_nets_version() { + for (s, _) in WARP_STYLES { + let id = format!("warp.{s}"); + let Some(e) = crate::new_effect(&id, &json!({"bend": 20})) else { panic!("{id}") }; + assert_eq!(e.params["version"], json!(crate::WARP_NET), "{id}"); + let Some(e) = crate::new_effect(&id, &json!({"version": 0})) else { panic!("{id}") }; + assert_eq!(e.params["version"], json!(0), "{id}"); + assert!(crate::merged_params(&id, &json!({"bend": 20})).get("version").is_none(), "{id}"); + } + let Some(e) = crate::new_effect("distort.twist", &json!({})) else { panic!("twist") }; + assert!(e.params.get("version").is_none()); +} + #[test] fn transform_copies_and_move() { let out = run("distort.transform", json!({"moveH": 20, "copies": 3}), &square()); diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index 84f196a8b4..f607abacdf 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -1,14 +1,22 @@ //! Warp effects: 15 envelope styles plus horizontal/vertical perspective distortion. //! -//! Each style is a map on normalised box coordinates (x, y) ∈ [-1, 1]² (y down). The path is -//! split into short pieces whose control points are mapped (see `map_nonlinear`). +//! A warp with `version` [`WARP_NET`] is its style's Bézier patch over the box ([`WarpNet`]); +//! one without (saved before the version existed) is a map on normalized box coordinates +//! (x, y) ∈ [-1, 1]² (y down), [`warp_point`]. The path is split into short pieces whose control +//! points are mapped (see `map_nonlinear`). use serde_json::Value; use vectorcraft_geom::{PathData, Point, Rect}; use crate::util::{map_nonlinear_within, num, text}; -pub use vectorcraft_doc::live::{WarpStyle, warp_point}; +pub use vectorcraft_doc::live::{WARP_NET, WarpNet, WarpStyle, uses_net, warp_point, warp_version}; + +/// The map version warp params `p` ask for: their `version`, 0 (the first map) when absent. +pub fn map_version(p: &Value) -> u32 { + let v = num(p, "version", 0.0); + if v.is_finite() && v >= 0.0 { v.min(f64::from(u32::MAX)) as u32 } else { 0 } +} /// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]). pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData { @@ -16,6 +24,17 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) - let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0; let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0; let vertical = text(p, "orientation", "horizontal").eq_ignore_ascii_case("vertical"); + let piece = b.width().hypot(b.height()) / 24.0; + if uses_net(map_version(p)) { + let net = WarpNet::new(style, bend, dh, dv, b.width(), b.height(), vertical); + let (w, h) = (b.width(), b.height()); + // A box with no width or no height takes the middle of the patch along that side. + let f = |q: Point| { + let (u, v) = (if w > 0.0 { (q.x - b.x0) / w } else { 0.5 }, if h > 0.0 { (q.y - b.y0) / h } else { 0.5 }); + b.origin() + net.at(u, v).to_vec2() + }; + return map_nonlinear_within(path, piece, max, f); + } let c = b.center(); let hw = (b.width() / 2.0).max(1e-9); let hh = (b.height() / 2.0).max(1e-9); @@ -29,17 +48,17 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) - }; Point::new(c.x + x2 * hw, c.y + y2 * hh) }; - let piece = b.width().hypot(b.height()) / 24.0; map_nonlinear_within(path, piece, max, f) } /// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with -/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the -/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline` -/// tests both sides of it). +/// no bend and no distortion. With the first map, Arc (`warp.arc`) takes a bend under 0.0001 % as +/// none: the threshold must match the one in [`warp_point`]'s Arc case +/// (`warp_bends_when_the_warp_moves_the_outline` tests both sides of it). With [`WarpNet`] any +/// bend moves the outline. pub(crate) fn bends(id: &str, p: &Value) -> bool { let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0; let bend = share("bend", 50.0); - let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 }; + let bent = if id == "warp.arc" && !uses_net(map_version(p)) { bend.abs() >= 1e-6 } else { bend != 0.0 }; bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0 } diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index b69b4e869d..005d892639 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -136,7 +136,7 @@ pub fn specs() -> Vec { "Make with Warp…", ["Object", "Envelope Distort"], Some("Cmd+Alt+Shift+W"), - "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}", + "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v, version?: 0|1 (1: each style bends the bounds as one Bézier patch; 0: VectorCraft's earlier map)} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}", has_selection, env_make_warp ), @@ -163,7 +163,7 @@ pub fn specs() -> Vec { "Reset with Warp…", ["Object", "Envelope Distort"], None, - "{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v} make the selected envelopes warp envelopes, keeping their content", + "{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v, version?: as makeWithWarp (unset: a warp envelope's own, else 1)} make the selected envelopes warp envelopes, keeping their content", has_envelope, env_reset_warp ), @@ -181,7 +181,7 @@ pub fn specs() -> Vec { "Envelope Info", [], None, - "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}", + "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, version (a warp's map: 1, each style as one Bézier patch; 0, the earlier map), rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}", has_doc, env_info ), @@ -199,7 +199,7 @@ pub fn specs() -> Vec { "Envelope Options…", ["Object", "Envelope Distort"], None, - "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)", + "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical, version?: 0|1 (warp envelopes' map, as makeWithWarp)} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)", has_doc, env_options ), diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index 1aceb49b54..a91fd8edd0 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -13,9 +13,14 @@ const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verti fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result { check_numbers(p, WARP_NUMBERS, cmd)?; - let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true); - if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur { - (style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0); + // A new warp takes its style's Bézier net; an edited one keeps the map it has unless `version` + // names one. + let (mut style, mut bend, mut h, mut v, mut horizontal, mut version) = ("arc".to_string(), 50.0, 0.0, 0.0, true, live::WARP_NET); + if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0, version: n0 }) = cur { + (style, bend, h, v, horizontal, version) = (s0.clone(), *b0, *h0, *v0, *z0, *n0); + } + if let Some(n) = p.get("version") { + version = live::warp_version(n).ok_or_else(|| bad(cmd, format!("`version` must be 0 or {}", live::WARP_NET)))?; } if let Some(s) = text_param(p, "style", cmd)? { let s = s.strip_prefix("warp.").unwrap_or(s); @@ -32,7 +37,7 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result o == "horizontal", None => given, }; - Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal }) + Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal, version }) } /// The options and fidelity new envelopes get (Envelope Options with no envelope selected). @@ -206,8 +211,8 @@ pub(super) fn env_info(s: &mut Session, _: &Value) -> Result { return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity)); }; let v = match &**kind { - EnvelopeKind::Warp { style, bend, h, v, horizontal } => { - json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal}) + EnvelopeKind::Warp { style, bend, h, v, horizontal, version } => { + json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal, "version": version}) } EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}), EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}), diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 4b8ae5d2be..60fa90c32c 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -317,6 +317,14 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") { return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`")); } + // A Warp's map is one this build draws: 0, the earlier map, or its style's Bézier net. + let newest = vectorcraft_render::effects::WARP_NET; + if effect.starts_with("warp.") + && let Some(v) = given.get("version") + && vectorcraft_render::effects::warp_version(v).is_none() + { + return Err(bad(id, format!("`version` must be 0 or {newest}"))); + } check_effect_color(id, effect, params)?; check_effect_channel(id, effect, params) } diff --git a/crates/engine/src/tests/effectedit.rs b/crates/engine/src/tests/effectedit.rs index 2d10c3f5d9..34e01bf31e 100644 --- a/crates/engine/src/tests/effectedit.rs +++ b/crates/engine/src/tests/effectedit.rs @@ -147,3 +147,37 @@ fn show_all_hidden_attributes() { s.execute("edit.undo", &json!({})).unwrap(); assert!(node(&s, id).appearance.has_hidden()); } + +/// A Warp applied now takes its style's Bézier net (`version` 1) unless a map is given, an edit +/// keeps the map the effect has (one saved before the version existed keeps the earlier map), and +/// a map this build doesn't draw is refused. +#[test] +fn warps_keep_the_map_their_version_names() { + let (mut s, id) = session_with_rect(); + let version = |s: &Session| node(s, id).appearance.effects[0].params.get("version").cloned(); + run(&mut s, "effect.apply", json!({"effect": "warp.flag", "params": {"bend": 40}})); + assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET))); + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 20}, "warpStyle": "arc"})); + assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET))); + // As a file saved before the version existed gives it. + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(Value::Object(p)) = doc.node_mut(id).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) { + p.remove("version"); + } + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 10, "horizontal": 20}})); + assert_eq!(version(&s), None); + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"version": 1}})); + assert_eq!(version(&s), Some(json!(1))); + run(&mut s, "effect.apply", json!({"effect": "warp.arc", "params": {"version": 0}})); + assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0)); + // A whole number given as a float is that version. + run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 1.0}})); + assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(1)); + run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 0.0}})); + assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0)); + for bad in [json!(2), json!(-1), json!(0.5), json!("1")] { + assert!(s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"version": bad}})).is_err(), "{bad}"); + assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"version": bad}})).is_err(), "{bad}"); + } + assert_eq!(node(&s, id).appearance.effects.len(), 2); +} diff --git a/crates/engine/src/tests/envelope.rs b/crates/engine/src/tests/envelope.rs index e4d030d387..4ffd7a361a 100644 --- a/crates/engine/src/tests/envelope.rs +++ b/crates/engine/src/tests/envelope.rs @@ -123,6 +123,51 @@ fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() { assert!(s.execute("object.envelope.resetWithMesh", &json!({})).is_err()); } +/// A new warp envelope bends with its style's Bézier net (`version` [`live::WARP_NET`]) and keeps +/// it through Reset with Warp. One saved before the version existed keeps the earlier map when its +/// warp or its options change. +#[test] +fn new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map() { + // The surface point a quarter of the way along the top edge. + let quarter = |s: &Session, e| live::EnvelopeMap::of(&node(s, e)).unwrap().surface_mesh(4, 4, vectorcraft_color::Color::BLACK).points[1].p; + let at = |x, y| vectorcraft_geom::Point::new(x, y); + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40})); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e)); + assert_eq!(s.execute("object.envelope.info", &json!({})).unwrap()["version"], json!(live::WARP_NET)); + // Flag moves the top edge 6 × bend × height × u(1 - u)(1 - 2u) down at u: 22.5 at u = 1/4. + assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e)); + s.execute("object.envelope.resetWithWarp", &json!({"bend": -40})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e)); + assert!(quarter(&s, e).distance(at(150.0, 77.5)) < 1e-9, "{:?}", quarter(&s, e)); + // As a file saved before the version existed gives it. + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(NodeKind::Envelope { kind, .. }) = doc.node_mut(e).map(|n| &mut n.kind) + && let EnvelopeKind::Warp { version, .. } = &mut **kind + { + *version = 0; + } + s.execute("object.envelope.options", &json!({"fidelity": 75})).unwrap(); + s.execute("object.envelope.resetWithWarp", &json!({"bend": 40})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e)); + // The earlier map's flag: half the bend times sin(π x), x = -1/2 here, in half heights. + assert!(quarter(&s, e).distance(at(150.0, 90.0)) < 1e-9, "{:?}", quarter(&s, e)); + // `version` moves a warp to a map, and a new warp can take the earlier one. + s.execute("object.envelope.options", &json!({"version": 1.0})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e)); + assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e)); + s.execute("object.envelope.resetWithWarp", &json!({"version": 0})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e)); + let (_, old) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40, "version": 0})); + assert!(matches!(kind(&s, old), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, old)); + for bad in [json!(2), json!(-1), json!(0.5), json!("1"), json!(null)] { + for cmd in ["object.envelope.makeWithWarp", "object.envelope.resetWithWarp", "object.envelope.options"] { + let r = s.execute(cmd, &json!({"version": bad})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("`version` must be 0 or 1")), "{cmd} {bad}: {r:?}"); + } + } +} + #[test] fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { let mut s = session(); @@ -141,9 +186,9 @@ fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { let content = node(&s, a); assert_eq!((content.opacity, content.blend, content.extras().mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look"); let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") }; - // The mesh follows the arc: its outline reaches below the content's bottom edge at the sides. + // The mesh follows the arc: its top corners swing out past the content's sides and up. let b = m.outline().bounds().unwrap(); - assert!(b.y1 > 201.0, "{b:?}"); + assert!(b.x0 < 99.0 && b.x1 > 301.0 && b.y0 < 99.0, "{b:?}"); assert_eq!(s.doc().unwrap().selection.objects, ids); } diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs index 8a1e46c68a..2dbef41238 100644 --- a/crates/engine/src/tests/envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -286,17 +286,18 @@ fn symbols_bend_with_the_envelope() { let e = arch(&mut s, 60.0, json!({})); let pts = anchors(&evaluated(&s, e)); assert!(pts.iter().any(|p| p.y < 90.0), "the symbol's art rose in the middle"); - // Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art). + // Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art). The + // arch lifts the middle of the 100..200 box by about 51, so (200, 120) is inside it. let mut r = vectorcraft_render::Renderer::new(); let region = Rect::new(0.0, 0.0, 400.0, 300.0); - assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] < 60); + assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] < 60); s.edit("edit symbol", |d, _| { let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(-10.0, -10.0, -8.0, -8.0)), Default::default()); d.symbols[0].art = Arc::new(art); Ok(()) }) .unwrap(); - assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] > 200); + assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] > 200); } fn strokes(nodes: &[Node]) -> usize { diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 9fc790efa2..33cc5749e1 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -483,8 +483,8 @@ fn envelope_warp_make_and_content_stays_inside_bounds() { let pts = all_anchors(&out); assert!(pts.len() > 4, "curves are subdivided"); assert!(pts.iter().all(|p| b.contains(*p)), "{b:?}"); - // It really is bent: the sides moved down. - assert!(pts.iter().any(|p| p.y > 201.0)); + // It really is bent: the top corners swung out past the sides. + assert!(pts.iter().any(|p| p.x < 99.0) && pts.iter().any(|p| p.x > 301.0)); } #[test] diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a99646c9c2..6549004c7d 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -6627,6 +6627,7 @@ fn every_effect_a_graphic_style_holds_is_read_back() { for id in &ids { cases.push((id, json!({"bend": 30.0, "horizontal": -10.0, "vertical": 20.0, "orientation": "vertical"}))); cases.push((id, json!({"bend": -30.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"}))); + cases.push((id, json!({"bend": 45.0, "horizontal": 15.0, "vertical": -5.0, "orientation": "horizontal", "version": 1}))); } for (id, params) in cases { let effect = fx(id, params.clone()); @@ -6643,6 +6644,10 @@ fn every_effect_a_graphic_style_holds_is_read_back() { let got = &back.graphic_styles[0].appearance.effects; assert_eq!(got.len(), 1, "{id} {params}"); assert_eq!(got[0].id, effect.id); + // A warp read from a file bends as its style's Bézier net. + if id.starts_with("warp.") { + assert_eq!(got[0].params["version"], json!(vectorcraft_doc::live::WARP_NET), "{id} {params}"); + } let (g, w) = (vectorcraft_effects::merged_params(&got[0].id, &got[0].params), vectorcraft_effects::merged_params(&effect.id, &effect.params)); for (key, value) in w.as_object().unwrap() { // Random isn't written, and a style with it isn't read; its seed is the default. @@ -6663,6 +6668,19 @@ fn every_effect_a_graphic_style_holds_is_read_back() { } } } + // A warp saved before the map version existed is written with its settings too, and reads back on + // the nets' map. + let old = vectorcraft_doc::Effect { id: "warp.flag".into(), params: json!({"bend": 40.0, "horizontal": 10.0}), visible: true }; + let mut doc = Document::new(200.0, 100.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Old", Appearance { effects: vec![old], ..Appearance::basic(black_fill(), Paint::None, 0.0) }); + g.id = 3; + *doc.graphic_styles = vec![g]; + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + let back = read_back(&doc); + let [warp] = back.graphic_styles[0].appearance.effects.as_slice() else { panic!("{:?}", back.graphic_styles) }; + assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["horizontal"]), ("warp.flag", &json!(40.0), &json!(10.0))); + assert_eq!(warp.params["version"], json!(vectorcraft_doc::live::WARP_NET)); // A Zig Zag with its 0 to 1 settings written under the size's own key, and a turn in radians. let mut doc = Document::new(200.0, 100.0); let effects = vec![ diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index c4ae9fda3b..c28c538bcf 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -889,9 +889,16 @@ pub(in crate::ai_editing) fn same_appearance(a: &Appearance, b: &Appearance) -> } /// Effect `e`'s settings with its defaults filled in, its blending mode in lower case and its color as -/// `#rrggbb`. +/// `#rrggbb`. A Warp's map `version` is left out: the file holds the warp's settings, and the reader +/// gives every warp it reads the newest map, so a warp saved before the version existed is written +/// with its settings as before. fn normalized_params(e: &Effect) -> Value { let mut p = vectorcraft_effects::merged_params(&e.id, &e.params); + if e.id.starts_with("warp.") + && let Value::Object(m) = &mut p + { + m.remove("version"); + } if let Some(m) = word(&p, "mode") { p["mode"] = Value::String(m); } diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 8b3fceecf3..50674860a6 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -244,6 +244,7 @@ fn warp(o: &Obj) -> Option { h: percent("DeformHoriz")?, v: percent("DeformVert")?, horizontal: !vertical, + version: vectorcraft_doc::live::WARP_NET, }) } diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index f7df4f35d6..911d8340cc 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1259,7 +1259,7 @@ mod tests { fn envelopes_in_symbol_definitions_spend_the_imports_checks() { use crate::import::ai::tests::{drawn_content, envelope_content, envelope_of, straight_mesh, warp_keys}; use vectorcraft_doc::live::EnvelopeKind; - let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: vectorcraft_doc::live::WARP_NET }; let envelope = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&want)); let mut reader = Reader::new(b""); diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index f15744f30c..ef661c1446 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -1159,6 +1159,7 @@ fn effect(f: &Filter, under: Under) -> Option { "horizontal": pct("DeformHoriz"), "vertical": pct("DeformVert"), "orientation": if flag("Rotate") { "vertical" } else { "horizontal" }, + "version": vectorcraft_doc::live::WARP_NET, }), ) } diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index d39a87b5c0..47086ca171 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -495,6 +495,8 @@ fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() { // The warp is the whole object's; the characters paint below every item. let [warp] = st.effects.as_slice() else { panic!("{:?}", st.effects) }; assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["orientation"]), ("warp.arch", &json!(5.0), &json!("horizontal"))); + // A file's warp bends as its style's Bézier net. + assert_eq!(warp.params["version"], json!(vectorcraft_doc::live::WARP_NET)); assert_eq!(st.contents_index, None); } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 46386d2200..8fa0a58346 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use kurbo::{Affine, Point, Rect}; use vectorcraft_color::gradient::GradientKind; use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::live::WARP_NET; use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Guide, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit}; use super::*; @@ -7403,15 +7404,16 @@ fn a_warp_envelope_opens_live() { for (style, bend, h, v, rotate, id) in [(1, 0.5, 0.0, 0.0, 0, "arc"), (1, 0.5, -0.2, 0.3, 1, "arc"), (8, 0.5, 0.0, 0.0, 0, "flag"), (15, 0.5, 0.0, 0.0, 0, "twist")] { - let want = EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0 }; + let want = + EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0, version: WARP_NET }; let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(style, bend, h, v, rotate), &envelope_content(), 1, &drawn_content(&want)); let r = read_ok(&layer(&body)); let a = art(&r.doc); assert_eq!(a.len(), 1, "{a:#?}"); let (content, kind, fidelity, editing, options) = live_envelope(&a[0]); - let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal } = kind else { panic!("{kind:?}") }; - assert_eq!((s.as_str(), *horizontal), (id, rotate == 0)); + let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal, version } = kind else { panic!("{kind:?}") }; + assert_eq!((s.as_str(), *horizontal, *version), (id, rotate == 0, WARP_NET)); assert!([(*b, bend), (*dh, h), (*dv, v)].iter().all(|(got, f)| (got - f * 100.0).abs() < 1e-9), "{kind:?}"); assert_eq!((fidelity, editing, options), (50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW)); assert_eq!(content.len(), 1, "{content:#?}"); @@ -7443,7 +7445,7 @@ fn warp_envelopes_read_every_style() { "twist", ]; for (n, id) in ids.iter().enumerate() { - let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; let keys = warp_keys(n as u32 + 1, 0.3, 0.0, 0.0, 0); let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &envelope_content(), 1, &drawn_content(&want)))); let a = art(&r.doc); @@ -7974,7 +7976,7 @@ fn an_envelope_that_doesnt_read_keeps_its_drawn_art() { #[test] fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { use vectorcraft_doc::live::EnvelopeKind; - let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true }; + let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&other)); let r = read_ok(&layer(&body)); let a = art(&r.doc); @@ -7989,7 +7991,7 @@ fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { fn a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { use vectorcraft_doc::live::EnvelopeKind; const WHITE: &str = "0 0 0 0 1 1 1 Xa\n"; - let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true }; + let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; let content = envelope_content().replace(RED, WHITE); let drawn = drawn_content(&other).replace(RED, WHITE); let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &content, 1, &drawn); diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 8f64280367..7353c5c190 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5419,7 +5419,14 @@ fn type_envelope_text(family: &str, paint: Option<&str>) -> String { fn arc_envelope(text: &Node, bend: f64) -> Node { let mut text = text.clone(); text.visible = true; - let kind = vectorcraft_doc::live::EnvelopeKind::Warp { style: "arc".into(), bend, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::live::EnvelopeKind::Warp { + style: "arc".into(), + bend, + h: 0.0, + v: 0.0, + horizontal: true, + version: vectorcraft_doc::live::WARP_NET, + }; Node::new( vectorcraft_doc::NodeId(1_000_000), NodeKind::Envelope { diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 3f5ebbb2fe..2b6610beb1 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -781,7 +781,7 @@ mod tests { d.symbols.push(Symbol { dynamic: false, name: format!("S{k}"), art: Arc::new(art) }); } let first = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S0".into(), xf: Affine::IDENTITY }); - let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![Arc::new(first)], kind: Box::new(kind), @@ -815,7 +815,7 @@ mod tests { d.symbols.push(Symbol { dynamic: false, name: name.into(), art: art.clone() }); } let content = ["A", "B", "C"].map(|s| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: s.into(), xf: Affine::IDENTITY }))); - let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: content.to_vec(), kind: Box::new(kind), diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 8f6d68f575..0e7864b0b9 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -338,7 +338,7 @@ fn enveloped_type_redraws_when_its_font_becomes_available() { let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 120.0)) }); let l = d.layers[0].id; for (i, content) in [typed, shown].into_iter().enumerate() { - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![Arc::new(content)], kind: Box::new(kind), @@ -376,7 +376,7 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() { let mut d = Document::new(300.0, 150.0); let style = CharStyle { size: 50.0, font_family: family.into(), ..Default::default() }; let typed = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 80.0), "Hamburg", style)))); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![Arc::new(typed)], kind: Box::new(kind), @@ -420,7 +420,7 @@ fn a_renderer_maps_enveloped_type_from_its_glyphs_in_the_current_font() { const FAMILY: &str = "Mapped Serif 4"; let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() }; let t = TextObject::point(Point::new(10.0, 60.0), "Hamburg", style); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = Node::new( vectorcraft_doc::NodeId(1), NodeKind::Envelope { diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index d5e6919f0d..f95735baec 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -1252,7 +1252,7 @@ fn art_with_live_objects_draws_pattern_layout_edits() { d.patterns.push(def); let band = vectorcraft_geom::shapes::rectangle(Rect::new(-80.0, -40.0, 80.0, 40.0)); let filled = Node::path(d.alloc_id(), band, vectorcraft_doc::Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0)); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let options = EnvelopeOptions { distort_appearance: true, distort_pattern_fills: true, ..Default::default() }; let envelope = Node::new( d.alloc_id(), @@ -1398,7 +1398,7 @@ fn live_objects_in_symbol_art_draw_symbol_and_pattern_edits() { /// An envelope of `d` bending `content` with an arc warp (30%); with `patterns`, Distort /// Appearance and Distort Pattern Fills are on, and the tiles of its pattern fills bend with it. fn arc_envelope(d: &mut Document, content: Vec, patterns: bool) -> Node { - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance: patterns, distort_pattern_fills: patterns, ..Default::default() }; let content = content.into_iter().map(Arc::new).collect(); Node::new( diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index fe1e5ee1d1..7e4007c6ac 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -439,7 +439,7 @@ fn transforms_of(t: &Node) -> [(Value, Affine); 3] { /// A 100×100 document holding `content` in an envelope that leaves it as it is (an arc with no /// bend), with Distort Appearance on or off. fn envelope_doc(content: Node, distort_appearance: bool) -> Document { - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance, ..Default::default() }; let envelope = NodeKind::Envelope { content: vec![Arc::new(content)], diff --git a/crates/render/src/tests/placed_document.rs b/crates/render/src/tests/placed_document.rs index c3c00a4cf0..3a65b1abe0 100644 --- a/crates/render/src/tests/placed_document.rs +++ b/crates/render/src/tests/placed_document.rs @@ -135,7 +135,7 @@ fn an_enveloped_placed_document_shows_its_file_once_read() { let placed = d.remove(NodeId(50)).unwrap(); let NodeKind::PlacedDocument(p) = &placed.kind else { panic!("a placed document") }; let p = (**p).clone(); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![placed], kind: Box::new(kind), diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index db3aad793e..32e1df2a16 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -1543,7 +1543,7 @@ mod tests { let d = doc_with_live_over_green(|d| { let a = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 10.0); let red = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 420.0, 320.0)), a); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let content = vec![std::sync::Arc::new(red)]; let n = Node::new( d.alloc_id(), @@ -1574,7 +1574,7 @@ mod tests { use vectorcraft_geom::{Affine, Rect}; let d = doc_with_live_over_green(|d| { let red = solid_rect(d, Rect::new(300.0, 300.0, 420.0, 320.0), Color::rgb(1.0, 0.0, 0.0)); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let content = vec![std::sync::Arc::new(red)]; let options = Default::default(); Node::new( diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 3a58ee3a9e..9e5c531c94 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -879,6 +879,13 @@ fn command(d: &Dialog) -> (&'static str, Value) { p["item"] = item.unwrap_or(Value::Null); p["params"] = params; let id = d.str("__effect"); + // Each target keeps the map its own Warp draws with: the dialog's `version` is the first + // target's. + if super::warp_effect::is_warp(&id) + && let Some(Value::Object(m)) = p.get_mut("params") + { + m.remove("version"); + } // The displayed style comes from the first target. Preserve other targets' Warp IDs // unless the user deliberately chooses Style, including after a preview rollback. if super::warp_effect::is_warp(&id) && d.bool("__warp_style_changed") { diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index f537595ebc..6c9d95b601 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -239,7 +239,7 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> { let env = st.selection.objects.first().and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))?; let NodeKind::Envelope { kind, editing, .. } = &env.kind else { return None }; let bar = match &**kind { - EnvelopeKind::Warp { style, bend, h, v, horizontal } => { + EnvelopeKind::Warp { style, bend, h, v, horizontal, .. } => { Bar::Warp { style: style.clone(), bend: *bend, h: *h, v: *v, horizontal: *horizontal } } EnvelopeKind::Mesh { rows, cols, .. } => Bar::Mesh { rows: *rows, cols: *cols }, diff --git a/crates/ui-egui/src/dialogs/tests_warp.rs b/crates/ui-egui/src/dialogs/tests_warp.rs index e7cbfcc5b7..21d3e8e1a4 100644 --- a/crates/ui-egui/src/dialogs/tests_warp.rs +++ b/crates/ui-egui/src/dialogs/tests_warp.rs @@ -616,3 +616,33 @@ fn warp_options_style_keyboard_enter_does_not_accept_dialog() { assert_eq!(app.session.doc().unwrap().doc, moved); } } + +/// Warp Options editing the Warps of two objects, one new and one saved without a map version, +/// changes the Bend of both and keeps each one's own `version`. +#[test] +fn warp_options_edit_keeps_each_targets_map_version() { + let (mut app, first) = app(); + app.run("effect.apply", json!({"effect": "warp.arc"})).unwrap(); + let second = NodeId(app.run("shape.rectangle", json!({"x": 230, "y": 60, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap()); + app.run("effect.apply", json!({"effect": "warp.arc"})).unwrap(); + // As a file saved before the version existed gives it. + let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc); + if let Some(Value::Object(p)) = doc.node_mut(second).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) { + p.remove("version"); + } + app.run("select.set", json!({"ids": [first.0, second.0]})).unwrap(); + app.run("effect.dialog", json!({"effect": "warp.arc"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, effect::EXISTS); + let ctx = context(); + settle(&mut app, &ctx); + click_text(&mut app, &ctx, "Edit"); + settle(&mut app, &ctx); + make_warp_edit(&mut app, &ctx, "bend"); + click_text(&mut app, &ctx, "OK"); + assert!(app.ui.dialog.is_none()); + let doc = app.session.doc().unwrap().doc.clone(); + let params = |id: NodeId| doc.node(id).unwrap().appearance.effects[0].params.clone(); + assert_eq!((¶ms(first)["bend"], ¶ms(first)["version"]), (&json!(25.0), &json!(vectorcraft_doc::live::WARP_NET))); + assert_eq!(params(second)["bend"], json!(25.0)); + assert!(params(second).get("version").is_none(), "{:?}", params(second)); +} diff --git a/crates/ui-egui/src/dialogs/warp_effect.rs b/crates/ui-egui/src/dialogs/warp_effect.rs index ff530fbdf2..e8ef2bb6b7 100644 --- a/crates/ui-egui/src/dialogs/warp_effect.rs +++ b/crates/ui-egui/src/dialogs/warp_effect.rs @@ -1,7 +1,7 @@ //! Effect > Warp controls. The style remains an effect ID; Envelope Warp has a different schema. use egui::{Pos2, Rect, Stroke, vec2}; use serde_json::{Map, Value, json}; -use vectorcraft_effects::{WARP_STYLES, WarpStyle, warp_point}; +use vectorcraft_effects::{WARP_STYLES, WarpNet, WarpStyle}; use super::form; use crate::state::Dialog; @@ -38,6 +38,8 @@ fn shown(label: &str) -> String { fn style_preview(ui: &mut egui::Ui, suffix: &str) { let (rect, _) = ui.allocate_exact_size(vec2(30.0, 22.0), egui::Sense::hover()); let Some(style) = WarpStyle::from_id(suffix) else { return }; + // The map new warps take, on a box 0.55 times as high as it is wide. + let net = WarpNet::new(style, 0.45, 0.0, 0.0, 2.0, 1.1, false); let mut lines = vec![]; let mut bounds = Rect::NOTHING; for vertical in [false, true] { @@ -46,8 +48,8 @@ fn style_preview(ui: &mut egui::Ui, suffix: &str) { .map(|i| { let along = -1.0 + f64::from(i) / 8.0; let (x, y) = if vertical { (fixed, along) } else { (along, fixed) }; - let (x, y) = warp_point(style, 0.45, 0.0, 0.0, 0.55, x, y); - let p = Pos2::new(x as f32, y as f32); + let q = net.at((x + 1.0) / 2.0, (y + 1.0) / 2.0); + let p = Pos2::new(q.x as f32, q.y as f32); bounds.extend_with(p); p }) diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2602b78bc4..c3aa528e54 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -28,7 +28,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Rasterize | Rasterize… (as a live effect) | 1/1 | — | | Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets | | SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists the document's own filters: VectorCraft ships no default filters. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned or skewed object in an imported SVG is left out with a warning | -| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; Arc is a circular arc in the object's own proportions (#1578) | +| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; each style bends the bounds as one Bézier patch (`version` 1), so Arc's edges are cubic curves close to concentric arcs; warps saved without a version keep the earlier map, where Arc is a circular arc in the object's own proportions (#1578) | Document Raster Effects Settings exists. When the 64 million pixel cap on an effect image lowers its resolution, PDF export warns once per object with the requested and the lowest achieved ppi diff --git a/docs/mcp.md b/docs/mcp.md index da260b6823..8003354a7a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8238,6 +8238,20 @@ share of the content's width or height in proportion to the length of its contro its curve parameter, so content spreads evenly along a segment without handles. An envelope opened from an `.ai` file keeps the corners the file saves, and they stay on the same anchors when the envelope is moved, rotated or reflected. These are VectorCraft's own rules, and existing top-object envelopes redraw by them. +A warp bends the content's bounds as one Bézier patch. The style sets the patch's control points from the bend and +the size of the bounds (Arc's top and bottom edges, for example, are cubic curves close to arcs around one center), then +the horizontal distortion scales each column of control points about its middle, and the vertical distortion scales +each row. Content with no width or no height takes the middle of the patch along that side. New warp envelopes, and +those opened from `.ai` files, carry `version` 1 for this map, and `object.envelope.info` reports it. Envelopes in +`.vectorcraft` documents saved before the version existed read as `version` 0, the earlier map, and keep their look, +and editing an envelope keeps its version. `version` (0 or 1) on `object.envelope.makeWithWarp`, +`object.envelope.resetWithWarp` and `object.envelope.options` sets the map. The Warp effects (`warp.arc` … +`warp.twist`) take the same map through the same `version` param: `effect.apply` gives a new Warp `version` 1 unless +`params.version` is 0, the built-in graphic styles' Warps have 1, and Effect › Warp edits keep each Warp's own version. +These commands refuse any other value with `` `version` must be 0 or 1 ``; `1.0` counts as 1. An `.ai` file holds a +warp's settings and not its version, so a `version` 0 warp saved to `.ai` and opened again draws with `version` 1. +These patches are VectorCraft's own rules; the styles and the ranges of Bend and the distortions are those of the Warp +options (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). `object.envelope.info` reads the selected envelope's settings (`type`, the warp's or mesh's values, `fidelity` and the options, `editing`), or with no envelope selected the options new envelopes get. With an envelope selected, `object.envelope.resetWithWarp` and `object.envelope.resetWithMesh` (`rows`, `cols`, `maintainShape`: true keeps diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cf4c0df1a9..f4c35ddd41 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -588,6 +588,7 @@ since 2026-10-01. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. +- **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. From 880cdea33cbdbf26c7ec10405e10e025fe0c1d1f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:16:20 -0800 Subject: [PATCH 14/79] Brushes: a built-in Dry Media library of grainy chalk, pencil, crayon and charcoal art brushes Window > Brush Libraries > Dry Media (`dry-media`) holds four art brushes: Chalk Grain, Graphite Pencil, Wax Crayon and Charcoal Dust. Each is 100 pt of filled vector paths generated from a fixed seed with integer steps and plain arithmetic, at most 80 paths and 500 anchors, stretched along the path with Tints colorization. The chalk and crayon art has even-odd gaps cut out of its band. The names, sizes and art are VectorCraft's own. A new i18n test checks that every complete catalog names each built-in brush library. --- crates/engine/src/cmd/brushlib.rs | 165 ++++++++++++++++++ crates/engine/src/tests/brushlib.rs | 140 ++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tests.rs | 14 ++ crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + .../src/panels/tests_brush_libraries.rs | 12 +- docs/gaps.md | 6 +- docs/mcp.md | 5 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 9 +- 35 files changed, 476 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 300d9f6aa1..c6d0b573c4 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -173,6 +173,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, 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 }, ]; /// Built-in library `id`, made once. @@ -446,6 +447,170 @@ fn energy_beams() -> BrushLibrary { built("Energy Beams", Document::new(100.0, 100.0), brushes) } +/// The fixed-seed number source of the Dry Media grain: integer steps and plain arithmetic, so the +/// art is the same on every run and every platform. +struct Grain(u64); + +impl Grain { + /// The next number in 0..1. + fn next(&mut self) -> f64 { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } + /// The next number in `a`..`b`. + fn range(&mut self, a: f64, b: f64) -> f64 { + a + (b - a) * self.next() + } +} + +/// `floor` at x = `x0` and `x1`, rising smoothly to 1 within `ramp` pt of each. +fn ends(x: f64, x0: f64, x1: f64, ramp: f64, floor: f64) -> f64 { + let t = ((x - x0).min(x1 - x) / ramp).clamp(0.0, 1.0); + floor + (1.0 - floor) * t * t * (3.0 - 2.0 * t) +} + +/// A closed band from x = `x0` to `x1` in `n` steps, at most `half` pt either side of y = 0 times +/// `taper(x)`; each edge point is pulled in by up to `rough` of that. +fn rough_band(g: &mut Grain, (x0, x1): (f64, f64), n: usize, half: f64, rough: f64, taper: impl Fn(f64) -> f64) -> PathData { + let xs: Vec = (0..=n).map(|i| x0 + (x1 - x0) * i as f64 / n as f64).collect(); + let mut pts: Vec = xs.iter().map(|&x| Point::new(x, -half * taper(x) * (1.0 - rough * g.next()))).collect(); + let bottom: Vec = xs.iter().rev().map(|&x| Point::new(x, half * taper(x) * (1.0 - rough * g.next()))).collect(); + pts.extend(bottom); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// A small four-cornered fleck around (`cx`, `cy`), at most `w` × `h` pt either side; one corner +/// in each direction, so its outline never crosses itself. +fn fleck(g: &mut Grain, cx: f64, cy: f64, w: f64, h: f64) -> PathData { + let mut side = |a: f64, b: f64| g.range(a, b); + let pts = [ + (cx - w * side(0.6, 1.0), cy + h * side(-0.3, 0.3)), + (cx + w * side(-0.3, 0.3), cy - h * side(0.6, 1.0)), + (cx + w * side(0.6, 1.0), cy + h * side(-0.3, 0.3)), + (cx + w * side(-0.3, 0.3), cy + h * side(0.6, 1.0)), + ]; + polygon(&pts) +} + +/// `body` with `holes` (inside it, apart from each other) cut out, even-odd, in gray `k`: what +/// lies under the stroke shows through the holes. +fn holed(body: PathData, holes: Vec, k: f32) -> Node { + let children = std::iter::once(body).chain(holes).map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect(); + let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd }); + n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::gray(k))))], ..Default::default() }.into(); + n +} + +/// Path `p` filled with gray `k` (1 is black). +fn ink(p: PathData, k: f32) -> Node { + filled(p, Paint::solid(Color::gray(k))) +} + +/// Slivers `w` × `h` pt (ranges) in `count` slots along x = 6..94 inside a band `half` pt wide +/// times `taper`. Each stays within `reach` of the band's half width from y = 0, taken where the +/// band is narrowest over the sliver and the 3 pt either side of it, so with `reach` below the +/// band's roughness margin no sliver touches the band's edge or another sliver. +fn slivers(g: &mut Grain, count: usize, half: f64, reach: f64, (w, h): ((f64, f64), (f64, f64)), taper: impl Fn(f64) -> f64) -> Vec { + let slot = 88.0 / count as f64; + (0..count) + .map(|i| { + let cx = 6.0 + slot * (i as f64 + 0.5); + let (w, h) = (g.range(w.0, w.1), g.range(h.0, h.1)); + let room = reach * half * taper(cx - w - 3.0).min(taper(cx + w + 3.0)); + let cy = g.range(-1.0, 1.0) * (room - h).max(0.0); + fleck(g, cx, cy, w, h) + }) + .collect() +} + +/// Dry Media: art brushes of grainy dry drawing marks, chalk, pencil, crayon and charcoal (original +/// vector grain from fixed seeds; every brush 100 pt long, black and grays that take the stroke +/// color as tints, gaps cut out so the paper shows). +pub(crate) fn dry_media() -> BrushLibrary { + // Chalk Grain, 100 × 8 pt: a soft band with narrow scratches and chips along its edges. + let chalk = { + let mut g = Grain(0x0c4a_1c0a); + let taper = |x: f64| ends(x, 0.0, 100.0, 10.0, 0.35); + let body = rough_band(&mut g, (0.0, 100.0), 36, 4.0, 0.25, taper); + let scratches = slivers(&mut g, 14, 4.0, 0.7, ((1.2, 2.4), (0.15, 0.3)), taper); + let mut parts = vec![holed(body, scratches, 0.8)]; + for i in 0..18 { + let cx = 3.0 + 94.0 * (i as f64 + g.range(0.2, 0.8)) / 18.0; + let side = if i % 2 == 0 { -1.0 } else { 1.0 }; + let cy = side * 4.0 * taper(cx) * g.range(0.7, 0.95); + let (w, h) = (g.range(0.3, 0.7), g.range(0.25, 0.5)); + let f = fleck(&mut g, cx, cy, w, h); + parts.push(ink(f, 1.0)); + } + group(parts) + }; + // Graphite Pencil, 100 × 2.5 pt: a solid tapered core with fine slanted hatching at its edges. + let pencil = { + let mut g = Grain(0x009e_4c11); + let core = rough_band(&mut g, (0.0, 100.0), 40, 0.8, 0.12, |x| ends(x, 0.0, 100.0, 18.0, 0.25)); + let mut parts = vec![ink(core, 1.0)]; + for i in 0..24 { + let x0 = 2.0 + 92.0 * (i as f64 + g.range(0.0, 0.4)) / 24.0; + let len = g.range(1.5, 3.0); + let t = g.range(0.12, 0.2); + let slant = g.range(0.1, 0.3); + let y0 = if i % 2 == 0 { -g.range(0.85, 1.05) } else { g.range(0.85, 1.25) - t - slant }; + parts.push(ink(polygon(&[(x0, y0 + slant), (x0 + len, y0), (x0 + len, y0 + t), (x0, y0 + slant + t)]), 0.6)); + } + group(parts) + }; + // Wax Crayon, 100 × 6 pt: a dense band with small gaps in its middle and short overlapping + // marks along both edges. + let crayon = { + let mut g = Grain(0x00c0_a7e5); + let taper = |x: f64| ends(x, 0.0, 100.0, 6.0, 0.5); + let body = rough_band(&mut g, (0.0, 100.0), 30, 2.6, 0.2, taper); + let gaps = slivers(&mut g, 16, 2.6, 0.34, ((0.6, 1.6), (0.15, 0.3)), taper); + let mut parts = vec![holed(body, gaps, 0.9)]; + for i in 0..36 { + let w = g.range(1.0, 2.2); + let cx = (1.0 + w).max(98.0 * (i as f64 + g.range(0.0, 1.0)) / 36.0).min(99.0 - w); + let side = if i % 2 == 0 { -1.0 } else { 1.0 }; + let h = g.range(0.4, 0.6); + // Clear of the gaps, which stay within 0.9 pt of the middle. + let inner = 0.9 + h; + let cy = side * g.range(inner, (2.4 * taper(cx)).max(inner)); + let f = fleck(&mut g, cx, cy, w, h); + parts.push(ink(f, 1.0)); + } + group(parts) + }; + // Charcoal Dust, 100 × 7 pt: a dark band broken into pieces, with flecks of dust either side. + let dust = { + let mut g = Grain(0xc4a2_c0a1); + let mut parts = vec![]; + let mut x0 = 0.0; + while x0 < 100.0 { + let x1 = (x0 + g.range(14.0, 24.0)).min(100.0); + let x1 = if 100.0 - x1 < 8.0 { 100.0 } else { x1 }; + let piece = rough_band(&mut g, (x0, x1), 10, 2.0, 0.3, |x| ends(x, 0.0, 100.0, 8.0, 0.4) * ends(x, x0, x1, 2.0, 0.3)); + parts.push(ink(piece, 1.0)); + x0 = x1 + g.range(1.5, 3.0); + } + for i in 0..30 { + let cx = 2.0 + 96.0 * (i as f64 + g.range(0.2, 0.8)) / 30.0; + let side = if g.next() < 0.5 { -1.0 } else { 1.0 }; + let s = g.range(0.15, 0.3); + let cy = side * g.range(2.3, 3.5 - s); + let f = fleck(&mut g, cx, cy, s, s); + parts.push(ink(f, if i % 3 == 0 { 0.7 } else { 1.0 })); + } + group(parts) + }; + let brushes = vec![ + art("Chalk Grain", chalk, Colorization::Tints), + art("Graphite Pencil", pencil, Colorization::Tints), + art("Wax Crayon", crayon, Colorization::Tints), + art("Charcoal Dust", dust, Colorization::Tints), + ]; + built("Dry Media", Document::new(100.0, 100.0), brushes) +} + // ---------- commands ---------- fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo { diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 491a471088..73b43b1a76 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let list = run(&mut s, "brush.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); + assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams", "dry-media"]); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); assert_eq!( @@ -124,6 +124,12 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] ); assert_eq!(libs[5]["count"].as_u64(), Some(4)); + let dry = run(&mut s, "brush.library", json!({"library": "Dry Media"})); + assert_eq!( + dry["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect::>(), + [("Chalk Grain", "art"), ("Graphite Pencil", "art"), ("Wax Crayon", "art"), ("Charcoal Dust", "art")] + ); + assert_eq!((libs[6]["name"].as_str(), libs[6]["count"].as_u64()), (Some("Dry Media"), Some(4))); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap(); let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]})); @@ -136,6 +142,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { ("scatter-shapes", "scatter"), ("game-trails", "art"), ("energy-beams", "art"), + ("dry-media", "art"), ] { let (_, lib) = cmd::brushlib::library(&s, id).unwrap(); assert!(lib.len() >= 4, "{id}"); @@ -165,6 +172,137 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err()); } +/// Whether `p` lies inside the polygon through `pts` (even-odd ray cast). +fn inside(p: vectorcraft_geom::Point, pts: &[vectorcraft_geom::Point]) -> bool { + let mut odd = false; + for (i, a) in pts.iter().enumerate() { + let b = pts[(i + 1) % pts.len()]; + if (a.y > p.y) != (b.y > p.y) && p.x < a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) { + odd = !odd; + } + } + odd +} + +/// Every anchor of the paths in `nodes` and their descendants. +fn anchors(nodes: &[&vectorcraft_doc::Node]) -> Vec { + let mut out = vec![]; + for n in nodes { + n.walk(&mut |c| out.extend(c.path_data().into_iter().flat_map(|p| p.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))))); + } + out +} + +/// Dry Media: four art brushes of grainy dry marks (chalk, pencil, crayon, charcoal) with names +/// of their own. Each one's art is a bounded set of filled vector paths 100 pt long that takes the +/// stroke color as tints, with real even-odd gaps in the chalk and the crayon; it draws finite art +/// on curved and short paths, is the same every time it is made, and goes into a document in one +/// undo step, once, applied, and through a saved library unchanged. +#[test] +fn the_dry_media_library_holds_grainy_art_brushes_with_bounded_art() { + use vectorcraft_brush::{ArtScale, Colorization}; + use vectorcraft_geom::FillRule; + let mut s = session(); + let (info, lib) = cmd::brushlib::library(&s, "dry-media").unwrap(); + assert_eq!((info.name.as_str(), info.category, lib.name.as_str()), ("Dry Media", "builtIn", "Dry Media")); + let names: Vec = lib.brushes.iter().map(|b| b.name.clone()).collect(); + assert_eq!(names, ["Chalk Grain", "Graphite Pencil", "Wax Crayon", "Charcoal Dust"]); + let mut total = 0; + for (b, (height, gaps)) in lib.brushes.iter().zip([(8.0, true), (2.5, false), (6.0, true), (7.0, false)]) { + let BrushKind::Art(a) = &b.kind else { panic!("{} is not an art brush", b.name) }; + assert_eq!((a.colorization, a.scale, a.width), (Colorization::Tints, ArtScale::Stretch, 100.0), "{}", b.name); + let (mut paths, mut holes) = (0, 0); + a.art.walk(&mut |n| match &n.kind { + NodeKind::Path { path, .. } => { + paths += 1; + assert!(path.subpaths.iter().all(|sp| sp.closed && sp.anchors.len() >= 4), "{}", b.name); + } + NodeKind::Compound { children, rule } => { + assert_eq!(*rule, FillRule::EvenOdd, "{}", b.name); + // The gaps lie inside the band and apart from each other, so each one is a hole. + let body = anchors(&[&*children[0]]); + let boxes: Vec = children[1..].iter().map(|c| c.path_data().unwrap().bounds().unwrap()).collect(); + for (i, c) in children[1..].iter().enumerate() { + assert!(anchors(&[&**c]).iter().all(|p| inside(*p, &body)), "{}: gap {i} leaves the band", b.name); + let r = boxes[i]; + assert!(boxes[..i].iter().all(|o| r.x0 >= o.x1 || o.x0 >= r.x1 || r.y0 >= o.y1 || o.y0 >= r.y1), "{}: gap {i} overlaps", b.name); + } + holes += children.len() - 1; + } + NodeKind::Group { .. } => {} + other => panic!("{}: unexpected {other:?}", b.name), + }); + let pts = anchors(&[&a.art]); + assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite())); + assert!(paths <= 80 && pts.len() <= 500, "{}: {paths} paths, {} anchors", b.name, pts.len()); + assert_eq!(holes > 0, gaps, "{}: {holes} gaps", b.name); + let r = vectorcraft_geom::Rect::from_points(pts[0], pts[0]); + let r = pts.iter().fold(r, |r, p| r.union_pt(*p)); + assert!(r.x0 >= 0.0 && r.x0 < 0.5 && r.x1 <= 100.0 && r.x1 > 99.5, "{}: {r:?}", b.name); + assert!((r.height() - height).abs() <= height * 0.25 && r.y0 < 0.0 && r.y1 > 0.0, "{}: {r:?}", b.name); + total += pts.len(); + // Finite art on a curve and on a 2 pt path. + for (line, width) in [(vec![(0.0, 0.0), (60.0, -40.0), (140.0, 40.0), (200.0, 0.0)], 1.0), (vec![(0.0, 0.0), (2.0, 0.0)], 3.0)] { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to(line[0]); + if line.len() == 4 { + bp.curve_to(line[1], line[2], line[3]); + } else { + bp.line_to(line[1]); + } + let stroke = vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), width); + let pieces = vectorcraft_brush::stroke_pieces(&lib.doc, b, &bp, &stroke); + let drawn = anchors(&pieces.iter().collect::>()); + assert!(!drawn.is_empty() && drawn.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{} on {line:?}", b.name); + } + } + assert!(total <= 1200, "{total} anchors"); + // Made again, the same bytes; the checksum pins them across runs and platforms. + let bytes = serde_json::to_string(&cmd::brushlib::dry_media().brushes).unwrap(); + assert_eq!(bytes, serde_json::to_string(&lib.brushes).unwrap()); + let fnv = bytes.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)); + assert_eq!(fnv, 12_859_086_488_513_909_464, "the Dry Media art changed"); + // Names of their own: no default brush's, no other built-in library's. + let defaults: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).collect(); + let others: Vec = cmd::brushlib::BUILTIN + .iter() + .filter(|b| b.id != "dry-media") + .flat_map(|b| cmd::brushlib::library(&s, b.id).unwrap().1.brushes.iter().map(|x| x.name.clone()).collect::>()) + .collect(); + assert!(names.iter().all(|n| !defaults.contains(&n.as_str()) && !others.contains(n)), "{names:?}"); + + // Added and applied to a path in one undo step, which undo takes back. + let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60}))["id"].as_u64().unwrap(); + let stroke_brush = |s: &Session| doc(s).node(NodeId(path)).unwrap().appearance.stroke().and_then(|st| st.brush.clone()); + let before = (undo_len(&s), brushes(&s)); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Dry Media", "name": "Wax Crayon", "apply": true, "ids": [path]})); + assert_eq!((strs(&r["added"]), r["applied"].clone()), (vec!["Wax Crayon"], json!([path]))); + assert_eq!((undo_len(&s), stroke_brush(&s).as_deref()), (before.0 + 1, Some("Wax Crayon"))); + assert_eq!(&brush(&s, "Wax Crayon"), lib.brush("Wax Crayon").unwrap()); + run(&mut s, "edit.undo", json!({})); + assert_eq!((brushes(&s), stroke_brush(&s)), (before.1.clone(), None)); + run(&mut s, "edit.redo", json!({})); + // The rest in one more step; then nothing new to add. + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "dry-media"})); + assert_eq!((strs(&r["added"]), strs(&r["existing"])), (vec!["Chalk Grain", "Graphite Pencil", "Charcoal Dust"], vec!["Wax Crayon"])); + assert_eq!(undo_len(&s), before.0 + 2); + assert_eq!( + brushes(&s), + [before.1.clone(), vec!["Wax Crayon".into(), "Chalk Grain".into(), "Graphite Pencil".into(), "Charcoal Dust".into()]].concat() + ); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "dry-media"})); + assert_eq!((strs(&r["added"]).len(), undo_len(&s)), (0, before.0 + 2)); + // Saved as a library and loaded back: the same brushes, in the document's order. + let saved = run(&mut s, "brush.saveLibrary", json!({ "names": names, "name": "Dry" })); + let b64 = saved["dataBase64"].as_str().unwrap().to_string(); + let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Dry.vectorcraft"})); + let (_, reopened) = cmd::brushlib::library(&s, r["library"].as_str().unwrap()).unwrap(); + assert_eq!(reopened.len(), lib.len()); + for b in &lib.brushes { + assert_eq!(reopened.brush(&b.name), Some(b), "{}", b.name); + } +} + #[test] fn a_saved_library_reopens_and_its_brushes_join_another_document_in_one_step() { let dir = folder("user"); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bc67d8fd87..67f69080db 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6022,6 +6022,11 @@ axis Y Axis Y ос Calligraphic Nibs Калиграфски писци Pattern Borders Бордюри с мостри Scatter Shapes Разсеяни фигури + Dry Media Сухи материали + Chalk Grain Тебеширено зърно + Graphite Pencil Графитен молив + Wax Crayon Восъчен пастел + Charcoal Dust Въглен прах Save Brush Library… Запази библиотека с четки… @msg no empty artboards to delete няма празни работни области за изтриване Scale Presets Пресети за мащаб diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6454cffcc9..9e20a00f2c 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6072,6 +6072,11 @@ axis Y Axis Y osa Calligraphic Nibs Kaligrafski vrhovi Pattern Borders Ivice od uzoraka Scatter Shapes Raspršeni oblici + Dry Media Suhi mediji + Chalk Grain Zrno krede + Graphite Pencil Grafitna olovka + Wax Crayon Voštana bojica + Charcoal Dust Ugljena prašina Save Brush Library… Sačuvaj biblioteku četkica… @msg `{_1}` has no brushes to apply `{_1}` nema četkica za primjenu @msg missing `library` (see brush.libraries) nedostaje `library` (pogledajte brush.libraries) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..40f96d0859 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3824,6 +3824,11 @@ digits Malayalam Malaiàlam Scatter Brush Options Opcions del pinzell de dispersió Scatter Graph Tool Eina de gràfic de dispersió Scatter Shapes Formes de dispersió + Dry Media Tècniques seques + Chalk Grain Gra de guix + Graphite Pencil Llapis de grafit + Wax Crayon Cera de colors + Charcoal Dust Pols de carbó Scatter: Dispersió: Scissors Tisores Scissors Tool Eina de tisores diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..78f43ab4a2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6271,6 +6271,11 @@ axis Y Axis osa Y Calligraphic Nibs Kaligrafická pera Pattern Borders Vzorkové okraje Scatter Shapes Rozptýlené tvary + Dry Media Suché techniky + Chalk Grain Křídové zrno + Graphite Pencil Grafitová tužka + Wax Crayon Vosková pastelka + Charcoal Dust Uhlový prach Save Brush Library… Uložit knihovnu štětců… @msg `{_1}` has no brushes to apply `{_1}` nemá žádné štětce k použití @msg missing `library` (see brush.libraries) chybí `library` (viz brush.libraries) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e259ecba84..22fe87c421 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6037,6 +6037,11 @@ axis Y Axis Y-akse Calligraphic Nibs Kalligrafiske penne Pattern Borders Mønsterborter Scatter Shapes Spredte former + Dry Media Tørre medier + Chalk Grain Kridtkorn + Graphite Pencil Grafitblyant + Wax Crayon Vokskridt + Charcoal Dust Kulstøv Save Brush Library… Gem penselbibliotek… @msg no empty artboards to delete ingen tomme tegneflader at slette Scale Presets Skaleringsforudindstillinger diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..87f5876eba 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5854,6 +5854,11 @@ axis Y Axis Y-Achse Calligraphic Nibs Kalligrafie-Federn Pattern Borders Musterbordüren Scatter Shapes Streuformen + Dry Media Trockene Medien + Chalk Grain Kreidekorn + Graphite Pencil Graphitstift + Wax Crayon Wachsmalstift + Charcoal Dust Kohlestaub Save Brush Library… Pinsel-Bibliothek speichern… @msg `{_1}` has no brushes to apply `{_1}` hat keine Pinsel zum Anwenden @msg missing `library` (see brush.libraries) `library` fehlt (siehe brush.libraries) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..efab4b490e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6222,6 +6222,11 @@ axis Y Axis Άξονας Y Calligraphic Nibs Καλλιγραφικές πένες Pattern Borders Περιθώρια μοτίβου Scatter Shapes Σχήματα διασποράς + Dry Media Ξηρά μέσα + Chalk Grain Κόκκος κιμωλίας + Graphite Pencil Μολύβι γραφίτη + Wax Crayon Κηρομπογιά + Charcoal Dust Σκόνη κάρβουνου Save Brush Library… Αποθήκευση βιβλιοθήκης πινέλων… @msg `{_1}` has no brushes to apply Η `{_1}` δεν έχει πινέλα για εφαρμογή @msg missing `library` (see brush.libraries) λείπει το `library` (δείτε brush.libraries) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..cbce0ab008 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6213,6 +6213,11 @@ axis Y Axis Eje Y Calligraphic Nibs Plumillas caligráficas Pattern Borders Bordes de motivo Scatter Shapes Formas de dispersión + Dry Media Técnicas secas + Chalk Grain Grano de tiza + Graphite Pencil Lápiz de grafito + Wax Crayon Cera de colores + Charcoal Dust Polvo de carboncillo Save Brush Library… Guardar biblioteca de pinceles… @msg `{_1}` has no brushes to apply `{_1}` no tiene pinceles para aplicar @msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries) diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..f4154755b7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6301,6 +6301,11 @@ axis Y Axis Y-akseli Calligraphic Nibs Kalligrafiakärjet Pattern Borders Kuvioreunukset Scatter Shapes Sirontamuodot + Dry Media Kuivat välineet + Chalk Grain Liitujyvä + Graphite Pencil Grafiittikynä + Wax Crayon Vahaliitu + Charcoal Dust Hiilipöly Save Brush Library… Tallenna sivellinkirjasto… @msg `{_1}` has no brushes to apply Kohteessa `{_1}` ei ole käytettäviä siveltimiä @msg missing `library` (see brush.libraries) `library` puuttuu (katso brush.libraries) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..4be80cbfba 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6266,6 +6266,11 @@ axis Y Axis Axe Y Calligraphic Nibs Plumes calligraphiques Pattern Borders Bordures de motif Scatter Shapes Formes de dispersion + Dry Media Techniques sèches + Chalk Grain Grain de craie + Graphite Pencil Crayon graphite + Wax Crayon Craie grasse + Charcoal Dust Poussière de fusain Save Brush Library… Enregistrer la bibliothèque de formes… @msg `{_1}` has no brushes to apply `{_1}` n'a aucune forme à appliquer @msg missing `library` (see brush.libraries) `library` manquant (voir brush.libraries) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..d8407860f2 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -434,6 +434,11 @@ antiAlias Sharp शार्प Plasma Arc प्लाज़्मा चाप Charge Wave चार्ज तरंग Spark Trail चिंगारी पथ + Dry Media सूखे माध्यम + Chalk Grain चॉक के दाने + Graphite Pencil ग्रेफाइट पेंसिल + Wax Crayon मोम क्रेयॉन + Charcoal Dust चारकोल धूल Art using a deleted global swatch keeps its colour. हटाए गए ग्लोबल स्वॉच वाला आर्टवर्क अपना रंग बनाए रखता है। Artboard आर्टबोर्ड Artboard {n} of {total} {total} में से आर्टबोर्ड {n} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..a24dcf20ee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6254,6 +6254,11 @@ axis Y Axis Y tengely Calligraphic Nibs Kalligrafikus tollhegyek Pattern Borders Mintázatszegélyek Scatter Shapes Szórt alakzatok + Dry Media Száraz technikák + Chalk Grain Krétaszemcse + Graphite Pencil Grafitceruza + Wax Crayon Zsírkréta + Charcoal Dust Szénpor Save Brush Library… Ecsetkönyvtár mentése… @msg `{_1}` has no brushes to apply A(z) `{_1}` nem tartalmaz alkalmazható ecsetet @msg missing `library` (see brush.libraries) hiányzik a `library` (lásd: brush.libraries) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04581cd727..bf4bf9af40 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6037,6 +6037,11 @@ axis Y Axis Sumbu Y Calligraphic Nibs Pena kaligrafi Pattern Borders Bingkai pola Scatter Shapes Bentuk sebar + Dry Media Media kering + Chalk Grain Butiran kapur + Graphite Pencil Pensil grafit + Wax Crayon Krayon lilin + Charcoal Dust Debu arang Save Brush Library… Simpan pustaka kuas… @msg no empty artboards to delete tidak ada artboard kosong untuk dihapus Scale Presets Preset skala diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..e1d073b1f2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5888,6 +5888,11 @@ axis Y Axis Asse Y Calligraphic Nibs Pennini calligrafici Pattern Borders Bordi a motivo Scatter Shapes Forme a dispersione + Dry Media Tecniche secche + Chalk Grain Grana di gesso + Graphite Pencil Matita di grafite + Wax Crayon Pastello a cera + Charcoal Dust Polvere di carboncino Save Brush Library… Salva libreria pennelli… @msg `{_1}` has no brushes to apply `{_1}` non ha pennelli da applicare @msg missing `library` (see brush.libraries) manca `library` (vedi brush.libraries) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..babf405210 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5953,6 +5953,11 @@ axis Y Axis Y 軸 Calligraphic Nibs カリグラフィペン先 Pattern Borders パターンボーダー Scatter Shapes 散布シェイプ + Dry Media ドライメディア + Chalk Grain チョークの粒子 + Graphite Pencil 黒鉛鉛筆 + Wax Crayon ワックスクレヨン + Charcoal Dust 木炭の粉 Save Brush Library… ブラシライブラリを保存… @msg `{_1}` has no brushes to apply `{_1}` に適用できるブラシがありません @msg missing `library` (see brush.libraries) `library` がありません (brush.libraries を参照) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..db7edfa9e7 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6222,6 +6222,11 @@ axis Y Axis Y ღერძი Calligraphic Nibs კალიგრაფიული წვერები Pattern Borders პატერნის ბორდიურები Scatter Shapes გაბნეული ფიგურები + Dry Media მშრალი მასალები + Chalk Grain ცარცის მარცვალი + Graphite Pencil გრაფიტის ფანქარი + Wax Crayon ცვილის ფანქარი + Charcoal Dust ნახშირის მტვერი Save Brush Library… ფუნჯების ბიბლიოთეკის შენახვა… @msg `{_1}` has no brushes to apply `{_1}`-ში გამოსაყენებელი ფუნჯები არ არის @msg missing `library` (see brush.libraries) `library` აკლია (იხ. brush.libraries) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..3af02c656c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5849,6 +5849,11 @@ axis Y Axis Y축 Calligraphic Nibs 서예 펜촉 Pattern Borders 패턴 테두리 Scatter Shapes 흩뿌리기 모양 + Dry Media 건식 재료 + Chalk Grain 분필 입자 + Graphite Pencil 흑연 연필 + Wax Crayon 왁스 크레용 + Charcoal Dust 목탄 가루 Save Brush Library… 브러시 라이브러리 저장… @msg `{_1}` has no brushes to apply `{_1}`에 적용할 브러시가 없습니다 @msg missing `library` (see brush.libraries) `library`가 없습니다(brush.libraries 참조) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6f37b432a..254a77856b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6088,6 +6088,11 @@ axis Y Axis Y-as Calligraphic Nibs Kalligrafische penpunten Pattern Borders Patroonranden Scatter Shapes Strooivormen + Dry Media Droge media + Chalk Grain Krijtkorrel + Graphite Pencil Grafietpotlood + Wax Crayon Waskrijt + Charcoal Dust Houtskoolstof Save Brush Library… Penseelbibliotheek opslaan… @msg no empty artboards to delete geen lege tekengebieden om te verwijderen Scale Presets Schaalvoorinstellingen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..833c381071 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6125,6 +6125,11 @@ axis Y Axis Oś Y Calligraphic Nibs Stalówki kaligraficzne Pattern Borders Obramowania wzorków Scatter Shapes Kształty rozproszone + Dry Media Techniki suche + Chalk Grain Ziarno kredy + Graphite Pencil Ołówek grafitowy + Wax Crayon Kredka woskowa + Charcoal Dust Pył węglowy Save Brush Library… Zapisz bibliotekę pędzli… @msg `{_1}` has no brushes to apply `{_1}` nie ma pędzli do zastosowania @msg missing `library` (see brush.libraries) brak `library` (zobacz brush.libraries) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..e39abfa421 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6186,6 +6186,11 @@ axis Y Axis Eixo Y Calligraphic Nibs Penas Caligráficas Pattern Borders Bordas de Padrão Scatter Shapes Formas de Dispersão + Dry Media Técnicas Secas + Chalk Grain Grão de Giz + Graphite Pencil Lápis de Grafite + Wax Crayon Giz de Cera + Charcoal Dust Pó de Carvão Save Brush Library… Salvar Biblioteca de Pincéis… @msg `{_1}` has no brushes to apply `{_1}` não tem pincéis para aplicar @msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..75a556f585 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5882,6 +5882,11 @@ axis Y Axis Ось Y Calligraphic Nibs Каллиграфические перья Pattern Borders Узорные каймы Scatter Shapes Рассеянные фигуры + Dry Media Сухие материалы + Chalk Grain Меловое зерно + Graphite Pencil Графитный карандаш + Wax Crayon Восковой мелок + Charcoal Dust Угольная пыль Save Brush Library… Сохранить библиотеку кистей… @msg `{_1}` has no brushes to apply В `{_1}` нет кистей для применения @msg missing `library` (see brush.libraries) не указан `library` (см. brush.libraries) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..0cb3950ca9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6290,6 +6290,11 @@ axis Y Axis Y-axel Calligraphic Nibs Kalligrafiska pennspetsar Pattern Borders Mönsterbårder Scatter Shapes Spridningsformer + Dry Media Torra medier + Chalk Grain Kritkorn + Graphite Pencil Grafitpenna + Wax Crayon Vaxkrita + Charcoal Dust Koldamm Save Brush Library… Spara penselbibliotek… @msg `{_1}` has no brushes to apply `{_1}` har inga penslar att använda @msg missing `library` (see brush.libraries) `library` saknas (se brush.libraries) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index c63e000795..47b860d0d1 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -184,6 +184,20 @@ fn font_height_reference_labels_are_in_complete_catalogs() { } } +/// The Window › Brush Libraries menu, built from the built-in library table, names each library. +#[test] +fn built_in_brush_library_names_are_in_complete_catalogs() { + let mut bad = vec![]; + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + let missing: Vec<&str> = + vectorcraft_engine::cmd::brushlib::BUILTIN.iter().map(|b| b.name).filter(|l| lang.catalog().plain(l).is_none()).collect(); + if !missing.is_empty() { + bad.push(format!("{} is missing {missing:?}", lang.code)); + } + } + assert!(bad.is_empty(), "{}", bad.join("\n")); +} + /// Preference choices and the marquee's E hint share the same translated labels. #[test] fn marquee_selection_labels_are_in_complete_catalogs() { diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..6d16b3e5f5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6227,6 +6227,11 @@ axis Y Axis Y ekseni Calligraphic Nibs Kaligrafi Uçları Pattern Borders Desen Kenarlıkları Scatter Shapes Dağılım Şekilleri + Dry Media Kuru Teknikler + Chalk Grain Tebeşir Dokusu + Graphite Pencil Grafit Kalem + Wax Crayon Mum Boya + Charcoal Dust Kömür Tozu Save Brush Library… Fırça Kitaplığını Kaydet… @msg `{_1}` has no brushes to apply `{_1}` içinde uygulanacak fırça yok @msg missing `library` (see brush.libraries) `library` eksik (bkz. brush.libraries) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..8ca9966c75 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5868,6 +5868,11 @@ axis Y Axis Вісь Y Calligraphic Nibs Каліграфічні пера Pattern Borders Візерункові облямівки Scatter Shapes Розсіяні фігури + Dry Media Сухі матеріали + Chalk Grain Крейдяне зерно + Graphite Pencil Графітний олівець + Wax Crayon Восковий олівець + Charcoal Dust Вугільний пил Save Brush Library… Зберегти бібліотеку пензлів… @msg `{_1}` has no brushes to apply У `{_1}` немає пензлів для застосування @msg missing `library` (see brush.libraries) не вказано `library` (див. brush.libraries) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..a07a2f65b5 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5869,6 +5869,11 @@ axis Y Axis Trục Y Calligraphic Nibs Ngòi bút thư pháp Pattern Borders Viền họa tiết Scatter Shapes Hình phân tán + Dry Media Chất liệu khô + Chalk Grain Hạt phấn + Graphite Pencil Bút chì than chì + Wax Crayon Bút sáp màu + Charcoal Dust Bụi than Save Brush Library… Lưu thư viện cọ… @msg `{_1}` has no brushes to apply `{_1}` không có cọ nào để áp dụng @msg missing `library` (see brush.libraries) thiếu `library` (xem brush.libraries) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..b009ebb080 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6109,6 +6109,11 @@ axis Y Axis Y 轴 Calligraphic Nibs 书法笔尖 Pattern Borders 图案边框 Scatter Shapes 散布形状 + Dry Media 干性画材 + Chalk Grain 粉笔颗粒 + Graphite Pencil 石墨铅笔 + Wax Crayon 蜡笔 + Charcoal Dust 炭粉 Save Brush Library… 保存画笔库… @msg `{_1}` has no brushes to apply `{_1}` 中没有可应用的画笔 @msg missing `library` (see brush.libraries) 缺少 `library`(请参阅 brush.libraries) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..9e51e4c17d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5906,6 +5906,11 @@ axis Y Axis Y 軸 Calligraphic Nibs 書法筆尖 Pattern Borders 圖樣邊框 Scatter Shapes 散佈形狀 + Dry Media 乾性畫材 + Chalk Grain 粉筆顆粒 + Graphite Pencil 石墨鉛筆 + Wax Crayon 蠟筆 + Charcoal Dust 炭粉 Save Brush Library… 儲存筆刷資料庫… @msg `{_1}` has no brushes to apply `{_1}` 中沒有可套用的筆刷 @msg missing `library` (see brush.libraries) 未提供 `library`(請參閱 brush.libraries) diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 6918e9118f..e57c127319 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -135,9 +135,12 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { json!("pattern-borders"), json!("scatter-shapes"), json!("game-trails"), - json!("energy-beams") + json!("energy-beams"), + json!("dry-media") ] ); + let dry = entries.iter().find(|e| under(e) && e.params["library"] == "dry-media").map(|e| e.label.as_str()); + assert_eq!(dry, Some("Dry Media")); let enabled = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled); assert_eq!(enabled("window.brushLibrary.other"), Some(true)); assert_eq!(enabled("ui.saveBrushLibrary"), Some(true), "a new document has the default brushes"); @@ -151,6 +154,13 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { for t in ["Open Brush Library", "Save Brush Library…"] { assert!(texts.iter().any(|x| x.ends_with(t)), "{t}: {texts:?}"); } + // Dry Media opens in a library panel, and a click on Chalk Grain adds it in one step. + let r = app.run("window.brushLibrary", json!({"library": "Dry Media"})).unwrap(); + assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("dry-media"), Some(4))); + let undo = undo_len(&app); + 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); // 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/docs/gaps.md b/docs/gaps.md index a228181abb..271bce11b4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -631,8 +631,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 (six original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter - Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a + its own (seven original libraries generated in code: Art Ribbons, Calligraphic Nibs, 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 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 @@ -650,7 +650,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert, and Status Effects with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the six shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the seven shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). diff --git a/docs/mcp.md b/docs/mcp.md index 7f6df79837..501e24d284 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3402,8 +3402,9 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: six generated in code (Art Ribbons, Calligraphic Nibs, Pattern -Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User +Brush libraries are read-only sets of brushes: seven generated in code (Art Ribbons, Calligraphic Nibs, 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 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). diff --git a/docs/roadmap.md b/docs/roadmap.md index 41066cbb49..92275336aa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -149,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | 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, 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, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, six generated libraries, 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, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, seven generated libraries including 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) | 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..7269f440a0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -218,7 +218,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 | | 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) | | 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 | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (six generated libraries including Game Trails and Energy Beams, 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 (seven generated libraries including 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 (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | 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`) | @@ -487,7 +487,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% | 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 (six generated in code including Game Trails and Energy Beams 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 (seven generated in code including 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% | 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, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -539,7 +539,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 five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries six (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) 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 five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries seven (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) 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 | @@ -836,7 +836,8 @@ 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, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) 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; 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). 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, Calligraphic Nibs, 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; 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). 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`. + - **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`). - 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. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. From ec36ccd07619132edc664209323f3130dc42be89 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:33:52 -0800 Subject: [PATCH 15/79] Cutting Tools: Weed Box and Lines adds a weed box and weeding lines around art MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Cutting Tools add-on command (Object › Plug-ins › Cutting Tools › Weed Box and Lines…, `addon.cutting.weed`) for vinyl cutting: around the selected art it adds a weed box, a rectangle at a margin outside the art's bounds, and weeding lines across the box, horizontal, vertical or both, by count or by spacing. Each line runs from one side of the box to the other and is left out over the art and within a gap of its outlines, so it stops at the art and the waste lifts off in pieces. The lines and the box are straight paths stroked in a chosen color swatch ([Registration] by default) with no fill, in one group named Weeding on top of the current layer, selected, as one undo step. While a clip group, or a group inside one, is edited in isolation, the group goes on top of the container that holds the outermost clip group around it. The art is measured by the outlines a cutter cuts: closed paths by their fill rule (the hole of an even-odd compound path gets lines too), open paths as lines, type by its glyph outlines, images by their frames, placed files by the art they show (by their boxes when the file can't be read), symbol instances by their art, and blends and other live objects by the art they draw. A clip group, and an object selected inside one, is measured by what shows inside the region its clipping paths clip to, and a clipping path selected on its own as its clip group. Art in a hidden group or layer is left out. The command refuses an empty selection, art with no outlines, bad params, and art or lines too complex to measure, with a message. The dialog has Preview; a Spacing above 0 is used instead of the Count. Docs: docs/mcp.md (Cutting Tools add-on) and docs/target-app-parity.md. --- crates/engine/src/cmd/addons/cutting.rs | 9 +- crates/engine/src/cmd/addons/cutting/weed.rs | 160 ++++ .../src/cmd/addons/cutting/weed/geometry.rs | 740 +++++++++++++++ .../src/cmd/addons/cutting/weed/tests.rs | 841 ++++++++++++++++++ crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/tests/command_sweep.rs | 6 + crates/ui-egui/src/addons/cutting.rs | 16 +- crates/ui-egui/src/addons/cutting/tests.rs | 67 ++ crates/ui-egui/src/dialogs/command.rs | 17 +- crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + docs/mcp.md | 42 +- docs/target-app-parity.md | 4 + 38 files changed, 2029 insertions(+), 9 deletions(-) create mode 100644 crates/engine/src/cmd/addons/cutting/weed.rs create mode 100644 crates/engine/src/cmd/addons/cutting/weed/geometry.rs create mode 100644 crates/engine/src/cmd/addons/cutting/weed/tests.rs diff --git a/crates/engine/src/cmd/addons/cutting.rs b/crates/engine/src/cmd/addons/cutting.rs index 8412af9a68..2734ca10b3 100644 --- a/crates/engine/src/cmd/addons/cutting.rs +++ b/crates/engine/src/cmd/addons/cutting.rs @@ -1,11 +1,16 @@ //! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil //! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together -//! ([`stencil`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialog are the UI's. +//! ([`stencil`]), and Weed Box and Lines adds a weed box and weeding lines around the selected art +//! for vinyl cutting ([`weed`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are +//! the UI's. mod stencil; +mod weed; use super::super::CommandSpec; pub fn specs() -> Vec { - stencil::specs() + let mut v = stencil::specs(); + v.extend(weed::specs()); + v } diff --git a/crates/engine/src/cmd/addons/cutting/weed.rs b/crates/engine/src/cmd/addons/cutting/weed.rs new file mode 100644 index 0000000000..a05770a024 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/weed.rs @@ -0,0 +1,160 @@ +//! Weed Box and Lines (`addon.cutting.weed`), a command of the built-in Cutting Tools add-on for +//! vinyl cutting: around the selected art it adds a weed box (a rectangle at a margin around the +//! art) and weeding lines across it that stop at the art, so the waste vinyl around the art lifts +//! off in pieces (). The art is measured by the +//! outlines a cutter cuts ([`geometry`]). + +mod geometry; +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Rect, shapes}; + +use super::super::super::edit::selected_roots; +use super::super::super::object::choice; +use super::super::super::paint::swatch_solid; +use super::super::super::pathops::{length_given, number_given}; +use super::super::super::{CommandSpec, bad, cmd, has_selection, switch_or, text_param}; +use crate::{EngineError, Result, Session}; + +/// Weed Box and Lines' command. +pub const WEED: &str = "addon.cutting.weed"; +/// Which way the weeding lines run. +pub const DIRECTIONS: [&str; 3] = ["horizontal", "vertical", "both"]; +/// The largest margin, gap or spacing, in points. +const MAX_DISTANCE: f64 = 100_000.0; +/// The most weeding lines each way. +const MAX_COUNT: usize = 1000; +/// The most pieces of weeding line one run adds. +const MAX_PIECES: usize = 100_000; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.cutting.weed", + "Weed Box and Lines…", + ["Object", "Plug-ins", "Cutting Tools"], + None, + "{margin?: pt 0..100000 (9: from the art to the weed box), box?: true (false: the weeding lines only), direction?: \"horizontal\" | \"vertical\" | \"both\" (\"both\"), count?: whole number 0..1000 of lines each way (1), spacing?: pt (instead of count: as few lines as keep them at most this far apart), gap?: pt 0..100000 (0: the lines stop at the art's outlines), weight?: pt 0.01..1000 (0.3), swatch?: a color swatch the lines are stroked in (\"[Registration]\")} add a weed box around the selected art and weeding lines across it that divide it evenly, each running from one side of the box to the other and left out over the art and within `gap` of its outlines, so the waste lifts off in pieces. The art is measured by the outlines a cutter cuts: closed paths enclose their area by their fill rule and open paths are lines, compound paths by all their subpaths, type by its glyph outlines, a clip group by what shows inside its clipping path, images by their frames, placed files by the art they show (by their boxes when the file can't be read), symbol instances by their art and live objects by the art they draw; strokes, effects and hidden objects are left out. One group named Weeding, stroked with no fill, on top of the current layer (outside the clip groups around a group edited in isolation), selected, as one undo step → {id: the group, box: its weed box or null, lines (pieces of weeding line)}", + has_selection, + weed + )] +} + +/// The distance `p` gives for `key` (0..[`MAX_DISTANCE`]), or `default`. +fn distance(p: &Value, key: &str, default: f64) -> Result { + match length_given(p, key, WEED)? { + None => Ok(default), + Some(v) if (0.0..=MAX_DISTANCE).contains(&v) => Ok(v), + Some(_) => Err(bad(WEED, format!("`{key}` must be from 0 to {MAX_DISTANCE} pt"))), + } +} + +/// How many lines divide a side `length` long: `count`, or as few as keep them at most `spacing` +/// apart. +fn lines_across(length: f64, count: Option, spacing: Option) -> Result { + let n = match spacing { + // `length / spacing` strips, rounded up, need one line fewer. + Some(s) => ((length / s - 1e-9).ceil() - 1.0).max(0.0), + None => return Ok(count.unwrap_or(1)), + }; + if n > MAX_COUNT as f64 { + return Err(bad(WEED, format!("`spacing` makes more than {MAX_COUNT} lines across the box"))); + } + Ok(n as usize) +} + +/// Where the Weeding group goes: `parent`, or, when `parent` is inside a clip group or clipping +/// sublayer (a group edited in isolation), the container around the outermost of them. A clipping +/// path would hide the lines and the box, which lie outside the art. +fn outside_clips(d: &Document, parent: Option) -> Option { + let clips = |id: NodeId| d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. })); + let (mut at, mut out) = (parent, parent); + while let Some(p) = at { + let up = d.parent_of(p); + if clips(p) { + match up { + Some(up) if d.is_editable(up) => out = Some(up), + _ => break, + } + } + at = up; + } + out +} + +fn weed(s: &mut Session, p: &Value) -> Result { + let margin = distance(p, "margin", 9.0)?; + let gap = distance(p, "gap", 0.0)?; + let with_box = switch_or(p, "box", true, WEED)?; + let direction = choice(p, WEED, "direction", &DIRECTIONS)?.unwrap_or("both"); + let count = match number_given(p, "count", WEED)? { + None => None, + Some(n) if n.fract() == 0.0 && (0.0..=MAX_COUNT as f64).contains(&n) => Some(n as usize), + Some(_) => return Err(bad(WEED, format!("`count` must be a whole number from 0 to {MAX_COUNT}"))), + }; + let spacing = match length_given(p, "spacing", WEED)? { + None => None, + Some(v) if v > 0.0 && v <= MAX_DISTANCE => Some(v), + Some(_) => return Err(bad(WEED, format!("`spacing` must be more than 0 and at most {MAX_DISTANCE} pt"))), + }; + if count.is_some() && spacing.is_some() { + return Err(bad(WEED, "give `count` or `spacing`, not both")); + } + let weight = match length_given(p, "weight", WEED)? { + None => 0.3, + Some(v) if (0.01..=1000.0).contains(&v) => v, + Some(_) => return Err(bad(WEED, "`weight` must be from 0.01 to 1000 pt")), + }; + let swatch = text_param(p, "swatch", WEED)?.unwrap_or(REGISTRATION); + let stroke = swatch_solid(&s.doc()?.doc, swatch, None).map_err(|e| bad(WEED, e))?; + let roots = selected_roots(s)?; + if roots.is_empty() { + return Err(bad(WEED, "select the art to weed")); + } + let d = &s.doc()?.doc; + let parent = outside_clips(d, s.doc()?.target_parent()?); + // Placed files' art uses their resources (symbols) under names of their own. + let placed = d.with_placed_art(); + let region = geometry::Reader::new(&placed).read_ids(&roots).map_err(|e| bad(WEED, e))?; + let region = region.ok_or_else(|| bad(WEED, "the selection has no outlines to weed around"))?; + let outline = region.outline().map_err(|e| bad(WEED, e))?; + let art = outline.iter().fold(None, |r: Option, &(a, b)| Some(r.map_or(Rect::from_points(a, b), |r| r.union_pt(a).union_pt(b)))); + let frame = art.ok_or_else(|| bad(WEED, "the selection has no outlines to weed around"))?.inflate(margin, margin); + if !(frame.width().is_finite() && frame.height().is_finite()) { + return Err(bad(WEED, "the selected art is too large to measure")); + } + let each = |on: bool, length: f64| if on { lines_across(length, count, spacing) } else { Ok(0) }; + let counts = + geometry::Counts { horizontal: each(direction != "vertical", frame.height())?, vertical: each(direction != "horizontal", frame.width())? }; + let pieces = geometry::lines(®ion, &outline, frame, counts, gap, MAX_PIECES).map_err(|e| bad(WEED, e))?; + if pieces.is_empty() && !with_box { + return Err(EngineError::Other("Weed Box and Lines: no weeding line fits outside the art, and the weed box is off".into())); + } + let ap = Appearance::basic(Paint::None, stroke, weight); + let (group, weed_box, lines) = s.edit("Weed Box and Lines", |d, sel| { + let mut children = vec![]; + let mut weed_box = None; + if with_box { + let mut b = Node::path(d.alloc_id(), shapes::rectangle(frame), ap.clone()); + b.name = Some("Weed Box".into()); + weed_box = Some(b.id); + children.push(Arc::new(b)); + } + for (a, b) in &pieces { + children.push(Arc::new(Node::path(d.alloc_id(), shapes::line(*a, *b), ap.clone()))); + } + let mut g = Node::group(d.alloc_id(), children); + g.name = Some("Weeding".into()); + let id = g.id; + d.insert(parent, usize::MAX, g)?; + sel.set([id]); + Ok((id, weed_box, pieces.len())) + })?; + Ok(json!({ "id": group.0, "box": weed_box.map(|b| b.0), "lines": lines })) +} diff --git a/crates/engine/src/cmd/addons/cutting/weed/geometry.rs b/crates/engine/src/cmd/addons/cutting/weed/geometry.rs new file mode 100644 index 0000000000..d9f612114b --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/weed/geometry.rs @@ -0,0 +1,740 @@ +//! The geometry of Weed Box and Lines: the selected art as the outlines a cutter cuts ([`Region`]), +//! and the weeding lines across a box that stop at those outlines or at a gap from them +//! ([`lines`]). +//! +//! A region is measured along one straight line at a time. A closed outline encloses its area by +//! its fill rule, an open one is a line with no area, a group is the union of its members and a +//! clip group is what its members draw inside its clipping path. Along a weeding line the region +//! gives the stretches inside its area ([`Region::spans`]), and its outline pieces +//! ([`Region::outline`]) give the stretches within the gap of an outline. + +use std::cell::Cell; + +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{BezPath, FillRule, PathEl, Point, Rect}; + +use super::super::super::super::pathops::node_path; + +/// How closely curves are followed, in points. +const TOLERANCE: f64 = 0.02; +/// The most points the outlines of one selection are measured by, once curves are followed in +/// straight segments (each subpath's points, its first one included). +pub(super) const MAX_POINTS: usize = 200_000; +/// The most tests of a segment against another one measuring a clip group's outlines takes. +const MAX_PAIRS: usize = 40_000_000; +/// The deepest groups, symbol instances and live objects are opened inside one another: as deep +/// as any object is drawn. +pub(super) const MAX_DEPTH: usize = vectorcraft_doc::depth::MAX_DRAWN_DEPTH; +/// The most pairs of an outline segment and a weeding line it comes near that one run measures. +const MAX_NEAR: usize = 10_000_000; +/// Pieces of weeding line shorter than this, in points, are left out. +const MIN_PIECE: f64 = 0.01; + +/// A straight segment. +pub(super) type Segment = (Point, Point); + +/// One subpath followed in straight segments. +#[derive(Clone, Debug)] +pub(super) struct Poly { + points: Vec, + closed: bool, + /// The box around `points`. + bounds: Rect, +} + +impl Poly { + /// The segments, with the closing one of a closed subpath. + fn segments(&self) -> impl Iterator + '_ { + let closing = self.closed.then(|| (self.points.last().copied(), self.points.first().copied())); + self.points.windows(2).map(|w| (w[0], w[1])).chain(closing.and_then(|(a, b)| Some((a?, b?)))) + } +} + +/// The selected art as the outlines a cutter cuts. +#[derive(Clone, Debug)] +pub(super) enum Region { + /// A path, compound path or type: its subpaths with the rule its closed ones fill by. + Shape { polys: Vec, rule: FillRule }, + /// Everything its members draw. + Union(Vec), + /// What `content` draws inside `clip`. + Clip { clip: Box, content: Box }, +} + +/// Reads the selected objects into a [`Region`], keeping count of the outline points. +pub(super) struct Reader<'a> { + doc: &'a Document, + points: usize, +} + +impl<'a> Reader<'a> { + pub(super) fn new(doc: &'a Document) -> Self { + Self { doc, points: 0 } + } + + /// The region of `nodes` together, `None` when they hold no outlines. + pub(super) fn read(&mut self, nodes: &[&Node]) -> Result, String> { + let mut parts = vec![]; + for n in nodes { + parts.extend(self.node(n, 0)?); + } + Ok(union(parts)) + } + + fn node(&mut self, n: &Node, depth: usize) -> Result, String> { + if depth > MAX_DEPTH { + return Err(format!("the selected art is nested more than {MAX_DEPTH} levels deep")); + } + if !n.visible { + return Ok(None); + } + match &n.kind { + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => Ok(None), + NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) => self.shape(n), + NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } => { + let members = n.editing_children(); + if *clip { + let Some((first, rest)) = members.split_first() else { return Ok(None) }; + let Some(clip) = self.clip_region(first)? else { return Ok(None) }; + let mut parts = vec![]; + for c in rest { + parts.extend(self.node(c, depth + 1)?); + } + Ok(union(parts).map(|content| Region::Clip { clip: Box::new(clip), content: Box::new(content) })) + } else { + let mut parts = vec![]; + for c in members { + parts.extend(self.node(c, depth + 1)?); + } + Ok(union(parts)) + } + } + NodeKind::Image(im) => { + let (w, h) = (f64::from(im.width), f64::from(im.height)); + let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(w, h), Point::new(0.0, h)].map(|p| im.xf * p); + self.polygon(&corners) + } + // An instance of a symbol the document doesn't have draws nothing. + NodeKind::SymbolInstance { .. } => match vectorcraft_render::effects::outline_object(Some(self.doc), n) { + Some(art) => self.node(&art, depth + 1), + None => Ok(None), + }, + NodeKind::PlacedDocument(p) => { + // The art of the file it shows; a file that can't be read shows its preview in its + // box, which may be rotated. + if vectorcraft_doc::placed_document::read_cached(p).is_none() { + return self.polygon(&p.corners()); + } + let art = vectorcraft_render::effects::expand_live_deep(Some(self.doc), n); + if std::mem::discriminant(&art.kind) == std::mem::discriminant(&n.kind) { + self.polygon(&p.corners()) + } else { + self.node(&art, depth + 1) + } + } + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { + // The art a live object draws, as the exporters write it; an object that stays + // as it is is measured by its box. + let art = vectorcraft_render::effects::expand_live_deep(Some(self.doc), n); + if std::mem::discriminant(&art.kind) == std::mem::discriminant(&n.kind) { self.frame(n) } else { self.node(&art, depth + 1) } + } + } + } + + /// The region a clipping path `first` clips to, whatever its own visibility: the region the + /// renderer clips to (a path, a compound path, type by its glyphs, a group's visible members, + /// an image's frame). `None` when it clips to nothing, and the clipped art is hidden. + fn clip_region(&mut self, first: &Node) -> Result, String> { + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(first) else { return Ok(None) }; + let mut polys = self.polys(outline)?; + // A region is filled: an open subpath clips as if closed. + polys.iter_mut().for_each(|p| p.closed = true); + Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule })) + } + + /// The region of the objects `ids` together, each within the clipping paths of the clip groups + /// and layers around it (an object selected inside a clip group shows only within its clipping + /// path, and not at all inside a hidden group or layer; a clipping path is measured as its clip + /// group), `None` when they show no outlines. + pub(super) fn read_ids(&mut self, ids: &[NodeId]) -> Result, String> { + let doc = self.doc; + let clips = |n: &Node| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); + // Every object's place in the tree, found in one walk. + let places = doc.index_paths(ids.iter().copied()); + let mut paths = vec![]; + for id in ids { + let Some(mut path) = places.get(id).cloned() else { continue }; + // A clipping path selected on its own stands for the art it shows: its clip group. + while let (Some(n), Some(up)) = (doc.node_at(&path), path.split_last().map(|(_, up)| up).and_then(|up| doc.node_at(up))) { + if !(clips(up) && up.editing_children().first().is_some_and(|f| f.id == n.id)) { + break; + } + path.pop(); + } + paths.push(path); + } + // An object selected twice, or inside another selected object, is measured once, with it. + paths.sort(); + paths.dedup(); + let mut kept: Vec> = vec![]; + for path in paths { + if !kept.last().is_some_and(|k| path.starts_with(k)) { + kept.push(path); + } + } + let mut parts = vec![]; + 'ids: for path in &kept { + let Some(n) = doc.node_at(path) else { continue }; + let Some(mut region) = self.read(&[n])? else { continue }; + // The containers around the object, innermost first. + let mut child = n.id; + for depth in (1..path.len()).rev() { + let Some(container) = path.get(..depth).and_then(|p| doc.node_at(p)) else { break }; + // Art in a hidden group or layer isn't shown. + if !container.visible { + continue 'ids; + } + if clips(container) + && let Some(first) = container.editing_children().first().filter(|f| f.id != child) + { + let Some(clip) = self.clip_region(first)? else { continue 'ids }; + region = Region::Clip { clip: Box::new(clip), content: Box::new(region) }; + } + child = container.id; + } + parts.push(region); + } + Ok(union(parts)) + } + + /// A path, compound path or type as its outlines. + fn shape(&mut self, n: &Node) -> Result, String> { + let Some((path, rule)) = node_path(n) else { return Ok(None) }; + let polys = self.polys(path.to_bezpath())?; + Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule })) + } + + /// The box of an object whose outlines can't be read. + fn frame(&mut self, n: &Node) -> Result, String> { + match n.geometric_bounds_with(&self.doc.symbol_boxes()) { + Some(r) => self.polygon(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]), + None => Ok(None), + } + } + + /// A closed polygon through `points`. + fn polygon(&mut self, points: &[Point]) -> Result, String> { + let poly = poly(points.to_vec(), true); + self.count(poly.iter().map(|p| p.points.len()).sum())?; + Ok(poly.map(|p| Region::Shape { polys: vec![p], rule: FillRule::NonZero })) + } + + fn count(&mut self, n: usize) -> Result<(), String> { + self.points = self.points.saturating_add(n); + if self.points > MAX_POINTS { Err(too_many()) } else { Ok(()) } + } + + /// The subpaths of `path` followed in straight segments. + fn polys(&mut self, path: BezPath) -> Result, String> { + let (mut out, mut current, points) = (vec![], vec![], Cell::new(0usize)); + let left = MAX_POINTS.saturating_sub(self.points); + // Past the budget no further curve is followed (one curve gives at most + // `MAX_FLATTEN_PER_CURVE` points), and the read fails below. + let elements = path.into_iter().take_while(|_| points.get() <= left); + vectorcraft_geom::flatten(elements, TOLERANCE, |el| { + points.set(points.get() + 1); + if points.get() > left { + return; + } + match el { + PathEl::MoveTo(p) => { + out.extend(poly(std::mem::take(&mut current), false)); + current.push(p); + } + PathEl::LineTo(p) => current.push(p), + PathEl::ClosePath => out.extend(poly(std::mem::take(&mut current), true)), + PathEl::QuadTo(..) | PathEl::CurveTo(..) => {} + } + }); + if points.get() > left { + return Err(too_many()); + } + out.extend(poly(current, false)); + self.count(out.iter().map(|p: &Poly| p.points.len()).sum())?; + Ok(out) + } +} + +fn too_many() -> String { + format!("the selected art has too many outline points to measure (more than {MAX_POINTS})") +} + +/// A subpath through `points`, `None` for fewer than two points or one that isn't finite. +fn poly(points: Vec, closed: bool) -> Option { + if points.len() < 2 || points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return None; + } + let first = *points.first()?; + let bounds = points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)); + Some(Poly { points, closed, bounds }) +} + +/// One region of `parts`, `None` when there are none. +fn union(mut parts: Vec) -> Option { + match parts.len() { + 0 => None, + 1 => parts.pop(), + _ => Some(Region::Union(parts)), + } +} + +/// `p` as (along, across) a weeding line: horizontal lines run along x, vertical ones along y. +fn uv(p: Point, vertical: bool) -> (f64, f64) { + if vertical { (p.y, p.x) } else { (p.x, p.y) } +} + +/// Sorts `spans` and joins those that overlap or touch. +fn merged(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> { + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out: Vec<(f64, f64)> = Vec::with_capacity(spans.len()); + for (a, b) in spans { + match out.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => out.push((a, b)), + } + } + out +} + +/// Where sorted, joined spans `a` and `b` overlap. +fn intersected(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { + let (mut i, mut j, mut out) = (0, 0, vec![]); + while let (Some(&(a0, a1)), Some(&(b0, b1))) = (a.get(i), b.get(j)) { + let (lo, hi) = (a0.max(b0), a1.min(b1)); + if lo <= hi { + out.push((lo, hi)); + } + if a1 < b1 { + i += 1; + } else { + j += 1; + } + } + out +} + +/// Where segment a→b crosses the line `across` = `c`, along it, and which way (1 when `across` +/// grows from a to b), `None` when it doesn't. Each crossing counts once: an end exactly on the +/// line counts as past it. +fn crosses(a: Point, b: Point, vertical: bool, c: f64) -> Option<(f64, i32)> { + let ((u0, v0), (u1, v1)) = (uv(a, vertical), uv(b, vertical)); + ((v0 <= c && v1 > c) || (v1 <= c && v0 > c)).then(|| (u0 + (c - v0) * (u1 - u0) / (v1 - v0), if v1 > v0 { 1 } else { -1 })) +} + +/// The stretches inside an area filled by `rule`, from where its outlines cross a line, sorted +/// along it. +fn filled(crossings: impl IntoIterator, rule: FillRule) -> Vec<(f64, f64)> { + let (mut winding, mut start, mut out) = (0, 0.0, vec![]); + let inside = |w: i32| match rule { + FillRule::NonZero => w != 0, + FillRule::EvenOdd => w % 2 != 0, + }; + for (u, dir) in crossings { + let was = inside(winding); + winding += dir; + match (was, inside(winding)) { + (false, true) => start = u, + (true, false) => out.push((start, u)), + _ => {} + } + } + merged(out) +} + +/// Where the closed outlines of a region's shapes cross a set of weeding lines: for each line, the +/// crossings as (shape, place along the line, direction), sorted by shape and then by place. Shapes +/// are numbered in the order [`Region::shapes`] lists them. +pub(super) type Crossings = Vec>; + +/// Where the shapes of each part of a region are numbered among its [`Crossings`]. +#[derive(Debug)] +pub(super) enum Layout { + Shape, + /// The number of each part's first shape, from the union's first, and the parts' layouts. + Union { + starts: Vec, + parts: Vec, + }, + /// The clipping path's shapes come first. + Clip { + clip_shapes: usize, + clip: Box, + content: Box, + }, +} + +impl Layout { + /// The layout of `region` and how many shapes it has. + pub(super) fn of(region: &Region) -> (Layout, usize) { + match region { + Region::Shape { .. } => (Layout::Shape, 1), + Region::Union(parts) => { + let (mut starts, mut layouts, mut n) = (vec![], vec![], 0); + for r in parts { + let (l, k) = Layout::of(r); + starts.push(n); + layouts.push(l); + n += k; + } + (Layout::Union { starts, parts: layouts }, n) + } + Region::Clip { clip, content } => { + let ((lc, a), (lt, b)) = (Layout::of(clip), Layout::of(content)); + (Layout::Clip { clip_shapes: a, clip: Box::new(lc), content: Box::new(lt) }, a + b) + } + } + } +} + +/// The [`Crossings`] of `region` with the lines `across` = v0 + (k + 1)·step, k < `count`. Errs +/// past [`MAX_NEAR`] crossings in all. +pub(super) fn crossings(region: &Region, vertical: bool, v0: f64, step: f64, count: usize) -> Result { + let mut shapes = vec![]; + region.shapes(&mut shapes); + let at = |k: usize| v0 + step * (k as f64 + 1.0); + // The lines a segment may cross: those from just below its lower end to its higher end. + let candidates = |(a, b): Segment| { + let (va, vb) = (uv(a, vertical).1, uv(b, vertical).1); + if step <= 0.0 || va == vb { + return 0..0; + } + let first = (((va.min(vb) - v0) / step).floor() - 1.0).clamp(0.0, count as f64); + let end = ((va.max(vb) - v0) / step).ceil().clamp(0.0, count as f64); + (first as usize)..(end as usize) + }; + let closed = |polys: &[Poly]| polys.iter().filter(|p| p.closed).flat_map(Poly::segments).collect::>(); + let total = shapes.iter().flat_map(|(polys, _)| closed(polys)).fold(0usize, |n, s| n.saturating_add(candidates(s).len())); + if total > MAX_NEAR { + return Err("the weeding lines cross the art's outlines too many times to measure; use fewer lines".into()); + } + let mut out: Crossings = vec![vec![]; count]; + for (i, (polys, _)) in shapes.iter().enumerate() { + for (a, b) in closed(polys) { + for k in candidates((a, b)) { + if let (Some((u, dir)), Some(line)) = (crosses(a, b, vertical, at(k)), out.get_mut(k)) { + line.push((i, u, dir)); + } + } + } + } + for line in &mut out { + line.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1))); + } + Ok(out) +} + +impl Region { + /// The stretches of the line `across` = `c` (horizontal: y = c, along x; vertical: x = c, + /// along y) inside the region's area, sorted and joined. + pub(super) fn spans(&self, vertical: bool, c: f64) -> Vec<(f64, f64)> { + match self { + Region::Shape { polys, rule } => { + let mut crossings: Vec<(f64, i32)> = vec![]; + for p in polys.iter().filter(|p| p.closed) { + let (lo, hi) = if vertical { (p.bounds.x0, p.bounds.x1) } else { (p.bounds.y0, p.bounds.y1) }; + if c >= lo && c <= hi { + crossings.extend(p.segments().filter_map(|(a, b)| crosses(a, b, vertical, c))); + } + } + crossings.sort_by(|a, b| a.0.total_cmp(&b.0)); + filled(crossings, *rule) + } + Region::Union(parts) => merged(parts.iter().flat_map(|r| r.spans(vertical, c)).collect()), + Region::Clip { clip, content } => intersected(&clip.spans(vertical, c), &content.spans(vertical, c)), + } + } + + /// The region's shapes with their fill rules, in a fixed order. + fn shapes<'a>(&'a self, out: &mut Vec<(&'a [Poly], FillRule)>) { + match self { + Region::Shape { polys, rule } => out.push((polys, *rule)), + Region::Union(parts) => parts.iter().for_each(|r| r.shapes(out)), + Region::Clip { clip, content } => { + clip.shapes(out); + content.shapes(out); + } + } + } + + /// [`Region::spans`] on a weeding line, from `line`, the line's [`Crossings`] with the + /// region's shapes, numbered from `base` (`layout` is the region's [`Layout`]). Only the parts + /// the line crosses are visited: a part it doesn't cross has no stretch inside its area. + pub(super) fn spans_on(&self, layout: &Layout, line: &[(usize, f64, i32)], base: usize) -> Vec<(f64, f64)> { + if line.is_empty() { + return vec![]; + } + match (self, layout) { + (Region::Shape { rule, .. }, Layout::Shape) => filled(line.iter().map(|&(_, u, dir)| (u, dir)), *rule), + (Region::Union(parts), Layout::Union { starts, parts: layouts }) => { + let (mut out, mut rest) = (vec![], line); + while let Some(&(id, ..)) = rest.first() { + // The part whose shapes the next crossing is on, and its crossings. + let j = starts.partition_point(|&s| base + s <= id).saturating_sub(1); + let (Some(r), Some(l), Some(&start)) = (parts.get(j), layouts.get(j), starts.get(j)) else { break }; + let end = starts.get(j + 1).map_or(usize::MAX, |&s| base + s); + let n = rest.partition_point(|c| c.0 < end); + if n == 0 { + break; + } + let (mine, after) = rest.split_at(n); + out.extend(r.spans_on(l, mine, base + start)); + rest = after; + } + merged(out) + } + (Region::Clip { clip, content }, Layout::Clip { clip_shapes, clip: lc, content: lt }) => { + let (a, b) = line.split_at(line.partition_point(|c| c.0 < base + clip_shapes)); + if a.is_empty() || b.is_empty() { + return vec![]; + } + intersected(&clip.spans_on(lc, a, base), &content.spans_on(lt, b, base + clip_shapes)) + } + _ => vec![], + } + } + + /// Is `p` inside the region's area or on one of `segments`, the outlines it shows? + fn contains(&self, p: Point, segments: &[Segment]) -> bool { + const EPS: f64 = 1e-7; + self.spans(false, p.y).iter().any(|&(a, b)| p.x >= a - EPS && p.x <= b + EPS) || segments.iter().any(|&(a, b)| distance(p, a, b) <= EPS) + } + + /// The pieces of outline the region shows: every outline of a shape and of a group's members, + /// and in a clip group the members' outlines inside the clipping path and the clipping path's + /// outline where the members' area or outlines are. Errs past [`MAX_PAIRS`]. + pub(super) fn outline(&self) -> Result, String> { + match self { + Region::Shape { polys, .. } => Ok(polys.iter().flat_map(Poly::segments).collect()), + Region::Union(parts) => { + let mut out = vec![]; + for r in parts { + out.extend(r.outline()?); + } + Ok(out) + } + Region::Clip { clip, content } => { + // Each side's area is bounded by the outlines it shows, so the pieces are cut and + // tested against those, never against art a nested clip hides. + let (clip_shown, content_shown) = (clip.outline()?, content.outline()?); + let mut work = 0; + let mut out = within(&content_shown, clip, &clip_shown, &mut work)?; + out.extend(within(&clip_shown, content, &content_shown, &mut work)?); + Ok(out) + } + } + } +} + +fn too_complex_clip() -> String { + "a clip group in the selection has too many outline segments to measure".into() +} + +/// The pieces of `segments` inside `region` (or on its outlines), cut where they cross +/// `region_segments`, the outlines the region shows. Each segment, and each piece of one tested +/// for being inside, adds the region's segment count to `work`, and the measure errs past +/// [`MAX_PAIRS`]. +fn within(segments: &[Segment], region: &Region, region_segments: &[Segment], work: &mut usize) -> Result, String> { + let mut out = vec![]; + let charge = |work: &mut usize| { + *work = work.saturating_add(region_segments.len().max(1)); + if *work > MAX_PAIRS { Err(too_complex_clip()) } else { Ok(()) } + }; + for &(a, b) in segments { + charge(work)?; + let mut cuts = vec![0.0, 1.0]; + let seg_box = Rect::from_points(a, b); + for &(c, d) in region_segments { + if touches(seg_box, Rect::from_points(c, d)) { + cuts.extend(crossing(a, b, c, d)); + } + } + cuts.sort_by(f64::total_cmp); + cuts.dedup_by(|x, y| (*x - *y).abs() < 1e-12); + let at = |t: f64| a + (b - a) * t; + for w in cuts.windows(2) { + let (t0, t1) = (w[0], w[1]); + if t1 - t0 < 1e-12 { + continue; + } + charge(work)?; + if region.contains(at((t0 + t1) / 2.0), region_segments) { + out.push((at(t0), at(t1))); + } + } + } + Ok(out) +} + +/// Do boxes `a` and `b` overlap or touch? +fn touches(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// Where along a→b (0 to 1) segment c→d crosses or touches it: one place, or the ends of the +/// shared stretch when the two lie on one line. +fn crossing(a: Point, b: Point, c: Point, d: Point) -> Vec { + let (r, s) = (b - a, d - c); + let denom = r.cross(s); + let qp = c - a; + let len2 = r.hypot2(); + if len2 == 0.0 { + return vec![]; + } + if denom.abs() <= 1e-12 * len2.sqrt() * s.hypot().max(1e-300) { + // Parallel: the ends of c→d on a→b when the two lie on one line. + if qp.cross(r).abs() > 1e-9 * len2.sqrt() { + return vec![]; + } + return [c, d].iter().map(|p| (*p - a).dot(r) / len2).filter(|t| (0.0..=1.0).contains(t)).collect(); + } + let t = qp.cross(s) / denom; + let u = qp.cross(r) / denom; + if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) { vec![t] } else { vec![] } +} + +/// The distance from `p` to segment a→b. +fn distance(p: Point, a: Point, b: Point) -> f64 { + let ab = b - a; + let len2 = ab.hypot2(); + let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 }; + (p - (a + ab * t)).hypot() +} + +/// The stretch of the line `across` = `c` within `gap` of segment a→b, `None` when the line +/// passes farther away. With no gap it is where the segment meets the line. +fn near(a: Point, b: Point, vertical: bool, c: f64, gap: f64) -> Option<(f64, f64)> { + let ((u0, v0), (u1, v1)) = (uv(a, vertical), uv(b, vertical)); + if gap <= 0.0 { + if v0 == v1 { + return (v0 == c).then_some((u0.min(u1), u0.max(u1))); + } + if c < v0.min(v1) || c > v0.max(v1) { + return None; + } + let u = u0 + (c - v0) * (u1 - u0) / (v1 - v0); + return Some((u, u)); + } + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + let mut take = |x: f64, y: f64| { + lo = lo.min(x); + hi = hi.max(y); + }; + // The round ends. + for (u, v) in [(u0, v0), (u1, v1)] { + let dv = (c - v).abs(); + if dv <= gap { + let h = (gap * gap - dv * dv).max(0.0).sqrt(); + take(u - h, u + h); + } + } + // The band along the segment: points whose foot falls on it, within the gap of it. + let (du, dv) = (u1 - u0, v1 - v0); + let len = du.hypot(dv); + if len > 0.0 { + // Each condition is linear in the position x along the line: k·x + m ≥ 0. + let mut range = (f64::NEG_INFINITY, f64::INFINITY); + let mut keep = |k: f64, m: f64| { + if k == 0.0 { + if m < 0.0 { + range = (1.0, 0.0); + } + } else if k > 0.0 { + range.0 = range.0.max(-m / k); + } else { + range.1 = range.1.min(-m / k); + } + }; + // The foot at or after a: (x - u0)·du + (c - v0)·dv ≥ 0. + keep(du, -u0 * du + (c - v0) * dv); + // The foot at or before b. + keep(-du, u0 * du - (c - v0) * dv + len * len); + // Within the gap on either side: |(x - u0)·dv - (c - v0)·du| ≤ gap·len. + let cross0 = -u0 * dv - (c - v0) * du; + keep(-dv, -cross0 + gap * len); + keep(dv, cross0 + gap * len); + if range.0 <= range.1 { + take(range.0, range.1); + } + } + (lo <= hi).then_some((lo, hi)) +} + +/// How many weeding lines run each way. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Counts { + pub horizontal: usize, + pub vertical: usize, +} + +/// The weeding lines across `frame`: `n.horizontal` horizontal and `n.vertical` vertical lines that +/// divide it evenly, each from one side of the frame to the other, left out where it is inside the +/// region's area or within `gap` of one of its outlines (`outline`). Pieces are in order along each +/// line, the horizontal lines first, top to bottom. Errs past [`MAX_NEAR`] pairs of an outline +/// segment and a line it comes within `gap` of, past [`MAX_NEAR`] crossings of the region's +/// outlines with the lines one way, or past `max_pieces` pieces. +pub(super) fn lines(region: &Region, outline: &[Segment], frame: Rect, n: Counts, gap: f64, max_pieces: usize) -> Result, String> { + let mut out = vec![]; + let (layout, _) = Layout::of(region); + for (vertical, count) in [(false, n.horizontal), (true, n.vertical)] { + let ((u0, v0), (u1, v1)) = (uv(Point::new(frame.x0, frame.y0), vertical), uv(Point::new(frame.x1, frame.y1), vertical)); + let step = (v1 - v0) / (count as f64 + 1.0); + // A frame with no height (for horizontal lines) or no width has no waste to divide. + if step <= 0.0 { + continue; + } + // The lines (0-based) each outline segment comes within `gap` of: lines k (1-based) lie at + // v0 + k·step. + let reach = |&(a, b): &Segment| { + let (va, vb) = (uv(a, vertical).1, uv(b, vertical).1); + let (lo, hi) = (va.min(vb) - gap, va.max(vb) + gap); + let first = ((lo - v0) / step).ceil().max(1.0); + let last = ((hi - v0) / step).floor().min(count as f64); + if step > 0.0 && first <= last { (first as usize - 1)..(last as usize) } else { 0..0 } + }; + // The outlines near each line, found once for all of them, by index into `outline`. + let pairs = outline.iter().fold(0usize, |sum, s| sum.saturating_add(reach(s).len())); + if pairs > MAX_NEAR { + return Err("the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap".into()); + } + let mut near_line: Vec> = vec![vec![]; count]; + for (j, s) in outline.iter().enumerate() { + for k in reach(s) { + if let Some(list) = near_line.get_mut(k) { + list.push(j); + } + } + } + let crossed = crossings(region, vertical, v0, step, count)?; + for (i, segments) in near_line.iter().enumerate() { + let c = v0 + step * (i as f64 + 1.0); + let mut blocked = region.spans_on(&layout, crossed.get(i).map(Vec::as_slice).unwrap_or_default(), 0); + blocked.extend(segments.iter().filter_map(|&j| outline.get(j)).filter_map(|&(a, b)| near(a, b, vertical, c, gap))); + let mut from = u0; + let point = |u: f64| if vertical { Point::new(c, u) } else { Point::new(u, c) }; + for (a, b) in merged(blocked) { + if a - from >= MIN_PIECE && a <= u1 { + out.push((point(from), point(a.min(u1)))); + } + from = from.max(b); + } + if u1 - from >= MIN_PIECE { + out.push((point(from), point(u1))); + } + if out.len() > max_pieces { + return Err(format!("the weeding lines would be more than {max_pieces} pieces")); + } + } + } + Ok(out) +} diff --git a/crates/engine/src/cmd/addons/cutting/weed/tests.rs b/crates/engine/src/cmd/addons/cutting/weed/tests.rs new file mode 100644 index 0000000000..280ae909bb --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/weed/tests.rs @@ -0,0 +1,841 @@ +//! Tests for `addon.cutting.weed`: a weed box around the selected art and weeding lines across it +//! that stop at the art's outlines. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{Appearance, Node, NodeKind}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes}; + +use super::super::super::super::pathops::text_outline; +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn line(s: &mut Session, pts: &[(f64, f64)]) -> NodeId { + let anchors: Vec = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +/// Runs Weed Box and Lines with `p` and gives the group it made. +fn weed(s: &mut Session, p: Value) -> (Value, Node) { + let r = s.execute("addon.cutting.weed", &p).unwrap(); + let g = node(s, id_of(&r)); + (r, g) +} + +/// The weed box's rectangle in group `g`, if it has one. +fn weed_box(g: &Node) -> Option { + g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Weed Box")).map(|c| c.geometric_bounds().unwrap()) +} + +/// The weeding lines in group `g` as their two ends, rounded to 1/1000 pt. +fn lines(g: &Node) -> Vec<((f64, f64), (f64, f64))> { + let r = |v: f64| (v * 1000.0).round() / 1000.0; + g.children() + .unwrap() + .iter() + .filter(|c| c.name.is_none()) + .map(|c| match &c.kind { + NodeKind::Path { path, .. } => { + let a: Vec = path.anchors().map(|(_, _, a)| a.p).collect(); + assert_eq!(a.len(), 2, "a weeding line is one straight segment"); + ((r(a[0].x), r(a[0].y)), (r(a[1].x), r(a[1].y))) + } + other => panic!("not a path: {other:?}"), + }) + .collect() +} + +#[test] +fn a_weed_box_and_lines_go_around_a_rectangle() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let undo = s.doc().unwrap().history.undo.len(); + let (r, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(g.name.as_deref(), Some("Weeding")); + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 160.0, 150.0))); + // One line each way through the middle, left out over the art. + assert_eq!( + lines(&g), + [((90.0, 120.0), (100.0, 120.0)), ((150.0, 120.0), (160.0, 120.0)), ((125.0, 90.0), (125.0, 100.0)), ((125.0, 140.0), (125.0, 150.0))] + ); + assert_eq!(r["lines"], json!(4)); + assert_eq!(r["box"], json!(weed_box_id(&g))); + // Stroked in Registration with no fill, selected, as one undo step that takes it all away. + for c in g.children().unwrap() { + assert_eq!(c.appearance.fill_paint(), Paint::None, "no fill"); + assert!(c.appearance.stroke_paint().is_registration()); + } + assert_eq!(s.doc().unwrap().selection.objects, [g.id]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(g.id).is_none()); +} + +fn weed_box_id(g: &Node) -> u64 { + g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Weed Box")).unwrap().id.0 +} + +#[test] +fn lines_stop_at_the_outline_and_cross_the_hole_of_a_compound_path() { + let mut s = session(); + let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let inner = rect(&mut s, 130.0, 130.0, 40.0, 40.0); + select(&mut s, &[outer, inner]); + let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + // Both subpaths wind the same way, so the fill rule alone decides whether the inner one is a + // hole. + let same_way = |rule: FillRule| { + move |d: &mut vectorcraft_doc::Document, _: &mut vectorcraft_doc::Selection| -> Result<()> { + let Some(NodeKind::Compound { children, rule: r }) = d.node_mut(c).map(|n| &mut n.kind) else { panic!("not a compound path") }; + *r = rule; + let area = |n: &Node| match &n.kind { + NodeKind::Path { path, .. } => path.subpaths[0].signed_area(), + _ => panic!("not a path"), + }; + let outer = area(&children[0]).signum(); + for child in children.iter_mut() { + if area(child).signum() != outer + && let NodeKind::Path { path, .. } = &mut std::sync::Arc::make_mut(child).kind + { + path.reverse(); + } + } + Ok(()) + } + }; + s.edit("even-odd", same_way(FillRule::EvenOdd)).unwrap(); + select(&mut s, &[c]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The waste in the hole is cut too; the ring itself is not. + assert_eq!(lines(&g), [((90.0, 150.0), (100.0, 150.0)), ((130.0, 150.0), (170.0, 150.0)), ((200.0, 150.0), (210.0, 150.0))]); + // With the non-zero rule the inner subpath fills, and no line crosses it. + s.edit("non-zero", same_way(FillRule::NonZero)).unwrap(); + select(&mut s, &[c]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(lines(&g), [((90.0, 150.0), (100.0, 150.0)), ((200.0, 150.0), (210.0, 150.0))]); +} + +#[test] +fn a_gap_keeps_the_lines_away_from_the_outline() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let (_, g) = weed(&mut s, json!({"margin": 20, "gap": 5, "direction": "horizontal"})); + assert_eq!(weed_box(&g), Some(Rect::new(80.0, 80.0, 170.0, 160.0))); + assert_eq!(lines(&g), [((80.0, 120.0), (95.0, 120.0)), ((155.0, 120.0), (170.0, 120.0))]); + // The gap is a distance from the outline: a line passing 5 pt beside a corner, with a gap of + // 6, stops √11 pt short of the corner's height. + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let b = rect(&mut s, 200.0, 60.0, 10.0, 120.0); + select(&mut s, &[a, b]); + let (_, g) = weed(&mut s, json!({"margin": 0, "gap": 6, "direction": "vertical"})); + assert_eq!(weed_box(&g), Some(Rect::new(100.0, 60.0, 210.0, 180.0))); + assert_eq!(lines(&g), [((155.0, 60.0), (155.0, 96.683)), ((155.0, 143.317), (155.0, 180.0))]); +} + +#[test] +fn a_box_with_no_height_gets_no_lines_across_it() { + // A horizontal line with no margin: the box is as flat as the line, and no weeding line runs + // along it. + let mut s = session(); + let l = line(&mut s, &[(100.0, 150.0), (200.0, 150.0)]); + let n = s.doc().unwrap().doc.node_count(); + assert!(s.execute("addon.cutting.weed", &json!({"margin": 0, "direction": "horizontal", "box": false})).is_err()); + assert_eq!(s.doc().unwrap().doc.node_count(), n, "nothing to add"); + select(&mut s, &[l]); + let (_, g) = weed(&mut s, json!({"margin": 0, "direction": "horizontal"})); + assert_eq!(weed_box(&g), Some(Rect::new(100.0, 150.0, 200.0, 150.0))); + assert_eq!(lines(&g), []); +} + +#[test] +fn an_open_path_stops_a_line_where_it_crosses() { + let mut s = session(); + line(&mut s, &[(100.0, 100.0), (100.0, 200.0)]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal", "gap": 2})); + assert_eq!(lines(&g), [((90.0, 150.0), (98.0, 150.0)), ((102.0, 150.0), (110.0, 150.0))]); +} + +#[test] +fn type_is_measured_by_its_glyph_outlines() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "OHO", "size": 72})).unwrap()); + let glyphs = match &node(&s, t).kind { + NodeKind::Text(t) => text_outline(t), + _ => panic!("not type"), + }; + let ink = glyphs.bounds().unwrap(); + let (_, g) = weed(&mut s, json!({"margin": 10, "count": 3, "direction": "horizontal"})); + let b = weed_box(&g).unwrap(); + for (got, want) in [(b.x0, ink.x0 - 10.0), (b.y0, ink.y0 - 10.0), (b.x1, ink.x1 + 10.0), (b.y1, ink.y1 + 10.0)] { + assert!((got - want).abs() < 0.05, "the box is 10 pt around the glyphs: {b:?} vs {ink:?}"); + } + // No piece of line runs over a glyph: each piece's middle is outside the ink. + let shape = glyphs.to_bezpath(); + let pieces = lines(&g); + assert!(pieces.len() > 3, "the letters break the lines: {pieces:?}"); + for ((x0, y0), (x1, y1)) in pieces { + let mid = Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0); + assert_eq!(shape.winding(mid), 0, "{mid:?} is outside the glyphs"); + } +} + +#[test] +fn a_clip_group_is_measured_by_what_shows() { + let mut s = session(); + let big = rect(&mut s, 0.0, 0.0, 400.0, 300.0); + let clip = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + select(&mut s, &[big, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let (_, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 160.0, 150.0))); + assert_eq!(lines(&g).len(), 4); + // Art that shows only partly: the clip path's edge where the art is, and the art's edge + // inside the clip. + let mut s = session(); + let art = rect(&mut s, 120.0, 0.0, 100.0, 300.0); + let clip = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + select(&mut s, &[art, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(weed_box(&g), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + assert_eq!(lines(&g), [((110.0, 120.0), (120.0, 120.0)), ((150.0, 120.0), (160.0, 120.0))]); +} + +#[test] +fn a_group_as_the_clipping_path_clips_by_its_members() { + // Make Clipping Mask takes a path, a compound path or type; an opened file can clip by a + // group, as this one does. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("clip group", |d, sel| { + let mut r = |x: f64, w: f64, y: f64, h: f64| { + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + w, y + h)), Appearance::default())) + }; + let (a, b, art) = (r(100.0, 50.0, 100.0, 40.0), r(250.0, 50.0, 100.0, 40.0), r(0.0, 400.0, 0.0, 300.0)); + let clip = Arc::new(Node::group(d.alloc_id(), vec![a, b])); + let mut g = Node::group(d.alloc_id(), vec![clip, art]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + let id = g.id; + d.insert(parent, usize::MAX, g)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The art shows through both rectangles, and the line is cut between them too. + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 310.0, 150.0))); + assert_eq!(lines(&g), [((90.0, 120.0), (100.0, 120.0)), ((150.0, 120.0), (250.0, 120.0)), ((300.0, 120.0), (310.0, 120.0))]); +} + +#[test] +fn lines_near_too_many_outline_segments_are_refused() { + let mut s = session(); + // A zigzag of 12,000 segments; with a gap as tall as the box, each of 1,000 lines comes near + // every segment. + let mut zigzag = BezPath::new(); + zigzag.move_to((100.0, 100.0)); + for i in 1..=12_000 { + zigzag.line_to((100.0 + f64::from(i) * 0.01, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("zigzag", |d, sel| { + let n = Node::path(d.alloc_id(), PathData::from_bezpath(&zigzag), Appearance::default()); + let id = n.id; + d.insert(parent, usize::MAX, n)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({"count": 1000, "gap": 50, "direction": "horizontal"})).unwrap_err(); + assert!(e.to_string().contains("too many outline segments"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); + // Fewer lines measure. + s.execute("addon.cutting.weed", &json!({"count": 10, "gap": 50, "direction": "horizontal"})).unwrap(); +} + +/// Adds a clip group, clipping path `clip` over `art` (both unpainted), and selects it. +fn clip_group(s: &mut Session, clip: PathData, art: PathData) -> NodeId { + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("clip group", |d, sel| { + let clip = Arc::new(Node::path(d.alloc_id(), clip, Appearance::default())); + let art = Arc::new(Node::path(d.alloc_id(), art, Appearance::default())); + let mut g = Node::group(d.alloc_id(), vec![clip, art]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + let id = g.id; + d.insert(parent, usize::MAX, g)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(s, &[id]); + id +} + +#[test] +fn an_open_clipping_path_clips_as_if_closed() { + let mut s = session(); + let mut v = BezPath::new(); + v.move_to((100.0, 100.0)); + v.line_to((150.0, 200.0)); + v.line_to((200.0, 100.0)); + clip_group(&mut s, PathData::from_bezpath(&v), shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 300.0))); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The art shows inside the triangle the V closes; the line stops at its sides. + assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 210.0, 210.0))); + assert_eq!(lines(&g), [((90.0, 150.0), (125.0, 150.0)), ((175.0, 150.0), (210.0, 150.0))]); +} + +#[test] +fn art_a_nested_clip_hides_adds_no_outline() { + // A clip group, clipping path A, holding a clip group whose clipping path B shows a small + // rectangle and hides a line that runs along A's bottom edge. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("nested clips", |d, sel| { + let mut path = |p: PathData| Arc::new(Node::path(d.alloc_id(), p, Appearance::default())); + let mut l = BezPath::new(); + l.move_to((0.0, 200.0)); + l.line_to((400.0, 200.0)); + let (b, shown, hidden) = ( + path(shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0))), + path(shapes::rectangle(Rect::new(110.0, 110.0, 140.0, 130.0))), + path(PathData::from_bezpath(&l)), + ); + let a = path(shapes::rectangle(Rect::new(50.0, 50.0, 300.0, 200.0))); + let clipped = |mut g: Node| { + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + g + }; + let inner = Arc::new(clipped(Node::group(d.alloc_id(), vec![b, shown, hidden]))); + let outer = clipped(Node::group(d.alloc_id(), vec![a, inner])); + let id = outer.id; + d.insert(parent, usize::MAX, outer)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let (_, g) = weed(&mut s, json!({"margin": 10, "count": 0})); + assert_eq!(weed_box(&g), Some(Rect::new(100.0, 100.0, 150.0, 140.0)), "only the shown rectangle is measured"); +} + +#[test] +fn art_selected_inside_a_clip_group_is_measured_by_what_shows() { + // The rectangle runs far outside the clipping path; selected on its own, it still shows only + // inside it. + let mut s = session(); + let clip = shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)); + let g = clip_group(&mut s, clip, shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0))); + let art = node(&s, g).children().unwrap()[1].id; + select(&mut s, &[art]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + assert_eq!(lines(&w), [((110.0, 120.0), (120.0, 120.0)), ((150.0, 120.0), (160.0, 120.0))]); + // The clipping path selected on its own is measured by the art it shows. + let clip_path = node(&s, g).children().unwrap()[0].id; + select(&mut s, &[clip_path]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + // With the art selected too, or the group, the group is measured once. + let d = &s.doc().unwrap().doc; + let outline = |ids: &[NodeId]| super::geometry::Reader::new(d).read_ids(ids).unwrap().unwrap().outline().unwrap().len(); + assert_eq!(outline(&[clip_path, art]), outline(&[g])); + assert_eq!(outline(&[g, art, g]), outline(&[g])); +} + +#[test] +fn art_in_a_hidden_group_is_left_out() { + let mut s = session(); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let b = rect(&mut s, 200.0, 100.0, 50.0, 40.0); + select(&mut s, &[a, b]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(g) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[a]); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "nothing selected shows"); + select(&mut s, &[a, shown]); + let (_, w) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&w), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); +} + +#[test] +fn clip_outlines_crossing_too_often_are_refused() { + // A clipping path whose 700 edges each run across the art, and open art whose 700 segments + // each run down across the clipping path: every pair crosses, and each of the 490,000 pieces + // of art between crossings would be tested against all 700 edges. + let zigzag = |across: bool| { + let mut p = BezPath::new(); + for i in 0..=700 { + let (along, side) = (100.0 + f64::from(i) * 0.25, if i % 2 == 0 { 100.0 } else { 300.0 }); + let pt = if across { (side, along) } else { (along, side) }; + if i == 0 { p.move_to(pt) } else { p.line_to(pt) } + } + p + }; + let mut clip = zigzag(true); + clip.close_path(); + let mut s = session(); + clip_group(&mut s, PathData::from_bezpath(&clip), PathData::from_bezpath(&zigzag(false))); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("too many outline segments"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); +} + +/// Each weeding line's stretches inside the art, found from the crossings indexed once for all the +/// lines, match those found by measuring the whole art along that line. +#[test] +fn indexed_crossings_find_the_same_stretches() { + let mut s = session(); + let mut ids = vec![]; + let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let inner = rect(&mut s, 130.0, 130.0, 40.0, 40.0); + select(&mut s, &[outer, inner]); + ids.push(id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap())); + let big = rect(&mut s, 220.0, 50.0, 200.0, 200.0); + let window = id_of(&s.execute("shape.ellipse", &json!({"x": 260, "y": 80, "width": 90, "height": 120})).unwrap()); + select(&mut s, &[big, window]); + ids.push(id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap())); + ids.push(id_of(&s.execute("text.create", &json!({"x": 100, "y": 320, "text": "WEED 8", "size": 60})).unwrap())); + ids.push(line(&mut s, &[(90.0, 40.0), (450.0, 380.0)])); + let a = rect(&mut s, 300.0, 300.0, 30.0, 30.0); + let b = id_of(&s.execute("shape.ellipse", &json!({"x": 320, "y": 310, "width": 60, "height": 40})).unwrap()); + select(&mut s, &[a, b]); + ids.push(id_of(&s.execute("object.group", &json!({})).unwrap())); + let d = &s.doc().unwrap().doc; + let nodes: Vec<&Node> = ids.iter().filter_map(|id| d.node(*id)).collect(); + let region = super::geometry::Reader::new(d).read(&nodes).unwrap().unwrap(); + let (layout, _) = super::geometry::Layout::of(®ion); + let count = 97; + for vertical in [false, true] { + let (v0, v1) = (30.0, 400.0); + let step = (v1 - v0) / (count as f64 + 1.0); + let crossed = super::geometry::crossings(®ion, vertical, v0, step, count).unwrap(); + assert_eq!(crossed.len(), count); + let mut hit = 0; + for (k, line) in crossed.iter().enumerate() { + let c = v0 + step * (k as f64 + 1.0); + let want = region.spans(vertical, c); + hit += usize::from(!want.is_empty()); + assert_eq!(region.spans_on(&layout, line, 0), want, "line {k}, vertical {vertical}"); + } + assert!(hit > count / 2, "most lines cross the art ({hit})"); + } +} + +#[test] +fn art_nested_too_deep_is_refused() { + // A rectangle inside `levels` groups, each holding the next. A document keeps none this deep, + // but the art symbol instances and live objects draw nests further. + let doc = vectorcraft_doc::Document::new(800.0, 600.0); + let nested = |levels: usize| { + let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default()); + for i in 0..levels { + n = Node::group(NodeId(i as u64 + 2), vec![Arc::new(n)]); + } + n + }; + let read = |n: &Node| super::geometry::Reader::new(&doc).read(&[n]); + assert!(read(&nested(super::geometry::MAX_DEPTH)).unwrap().is_some()); + let e = read(&nested(super::geometry::MAX_DEPTH + 1)).unwrap_err(); + assert!(e.contains("nested more than"), "{e}"); +} + +#[test] +fn spacing_divides_the_box_into_strips_no_wider() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 10.0, 40.0); + // The box is 60 pt tall: three strips of 20 pt. + let (_, g) = weed(&mut s, json!({"margin": 10, "spacing": 25, "direction": "horizontal", "box": false})); + assert_eq!(weed_box(&g), None); + let ys: Vec = lines(&g).iter().map(|l| l.0.1).collect(); + assert_eq!(ys, [110.0, 110.0, 130.0, 130.0]); + let mut s = session(); + rect(&mut s, 100.0, 100.0, 10.0, 40.0); + assert!(s.execute("addon.cutting.weed", &json!({"spacing": 0.01})).is_err(), "too many lines"); +} + +#[test] +fn the_lines_take_a_swatch() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Cut", "color": "cmyk 0 100 0 0", "spot": true})).unwrap(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let (_, g) = weed(&mut s, json!({"swatch": "Cut", "weight": 1})); + for c in g.children().unwrap() { + match c.appearance.stroke_paint() { + Paint::Solid { swatch, .. } => assert_eq!(swatch.as_deref(), Some("Cut")), + other => panic!("stroke {other:?}"), + } + assert_eq!(c.appearance.stroke_width(), 1.0); + } +} + +/// Adds the object `make` makes with a new id to the current layer, selects it and gives its id. +fn insert(s: &mut Session, make: impl FnOnce(NodeId) -> Node) -> NodeId { + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("insert", |d, _| { + let n = make(d.alloc_id()); + let id = n.id; + d.insert(parent, usize::MAX, n)?; + Ok(id) + }) + .unwrap(); + select(s, &[id]); + id +} + +/// A 100 pt square turned 45° about (300, 300): a diamond 141.42 pt across. +fn diamond_xf() -> Affine { + Affine::translate((300.0, 300.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4) * Affine::translate((-50.0, -50.0)) +} + +/// The weeding lines of `g` at height `y` (to 1/1000 pt). +fn at(g: &Node, y: f64) -> Vec<(f64, f64)> { + lines(g).into_iter().filter(|((_, y0), (_, y1))| *y0 == y && *y1 == y).map(|((x0, _), (x1, _))| (x0, x1)).collect() +} + +/// The diamond's weeding with three horizontal lines and a margin of 10: the box, and where the +/// first line's left piece ends. +fn diamond_weeding(s: &mut Session) -> (Rect, f64) { + let (_, g) = weed(s, json!({"margin": 10, "count": 3, "direction": "horizontal"})); + let b = weed_box(&g).unwrap(); + let y = ((b.y0 + b.height() / 4.0) * 1000.0).round() / 1000.0; + let first = at(&g, y); + assert_eq!(first.len(), 2, "{first:?}"); + (b, first[0].1) +} + +#[test] +fn an_image_is_measured_by_its_frame_as_turned() { + let mut s = session(); + let im = vectorcraft_doc::ImageObject { + key: "image".into(), + width: 100, + height: 100, + xf: diamond_xf(), + link: None, + placement: Default::default(), + pixels: None, + }; + insert(&mut s, |id| Node::new(id, NodeKind::Image(Box::new(im)))); + let (b, end) = diamond_weeding(&mut s); + let half = 50.0 * std::f64::consts::SQRT_2; + assert!((b.x0 - (290.0 - half)).abs() < 1e-3 && (b.y1 - (310.0 + half)).abs() < 1e-3, "{b:?}"); + // A quarter of the box down, the line meets the diamond's upper left edge, not its bounds. + let y = b.y0 + b.height() / 4.0; + assert!((end - (300.0 - (y - (300.0 - half)))).abs() < 0.01, "{end} at {y}"); +} + +#[test] +fn a_placed_file_is_measured_by_the_art_it_shows() { + let dir = crate::tests_links::Folder::new("weed-placed"); + // A file with a 60 × 40 artboard and a 20 pt wide rectangle at each side (hidden when + // `hidden`), placed centered on (200, 150) and selected. + let place = |s: &mut Session, name: &str, hidden: bool| { + let src = dir.file(name); + let mut d = vectorcraft_doc::Document::new(60.0, 40.0); + let layer = d.layers[0].id; + for x in [0.0, 40.0] { + let id = d.alloc_id(); + let look = Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0); + let mut n = Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 40.0)), look); + n.visible = !hidden; + d.insert(Some(layer), 0, n).unwrap(); + } + crate::tests_links::write(&src, &vectorcraft_format::save_file(&d)); + let r = s.execute("file.place", &json!({"path": src, "at": [200, 150]})).unwrap(); + select(s, &[NodeId(r["ids"][0].as_u64().unwrap())]); + }; + let mut s = session(); + place(&mut s, "pair.vectorcraft", false); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The rectangles lie at 170..190 and 210..230; the line runs between them too. + assert_eq!(at(&g, 150.0), [(160.0, 170.0), (190.0, 210.0), (230.0, 240.0)]); + // A file read but shown in a box with no size shows nothing, and its box isn't measured. + let id = NodeId(s.execute("file.place", &json!({"path": dir.file("pair.vectorcraft"), "at": [400, 150]})).unwrap()["ids"][0].as_u64().unwrap()); + s.edit("no size", |d, _| { + if let Some(NodeKind::PlacedDocument(p)) = d.node_mut(id).map(|n| &mut n.kind) { + (p.width, p.height) = (0.0, 0.0); + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[id]); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("has no outlines"), "{e}"); + // A file read with nothing showing has no outlines, and its box isn't measured instead. + let mut s = session(); + place(&mut s, "hidden.vectorcraft", true); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("has no outlines"), "{e}"); + // A file whose art is an instance of a symbol of its own: the instance's art is measured. + let src = dir.file("symbol.vectorcraft"); + let mut d = vectorcraft_doc::Document::new(60.0, 40.0); + let look = Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0); + let art = Node::path(NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 40.0)), look); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(art) }); + let (layer, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY })).unwrap(); + crate::tests_links::write(&src, &vectorcraft_format::save_file(&d)); + let mut s = session(); + let r = s.execute("file.place", &json!({"path": src, "at": [200, 150]})).unwrap(); + select(&mut s, &[NodeId(r["ids"][0].as_u64().unwrap())]); + let (_, g) = weed(&mut s, json!({"margin": 10})); + let b = weed_box(&g).unwrap(); + assert!(b.x0 >= 160.0 - 1e-6 && b.x1 <= 240.0 + 1e-6 && b.width() < 41.0, "the bar, not the whole box: {b:?}"); +} + +#[test] +fn a_placed_file_that_cant_be_read_is_measured_by_its_box_as_turned() { + let mut s = session(); + let p = vectorcraft_doc::PlacedDocument { + link: vectorcraft_doc::LinkInfo::new("/no/such/file.pdf"), + key: "missing".into(), + bounding: false, + page_box: Default::default(), + width: 100.0, + height: 100.0, + xf: diamond_xf(), + placement: Default::default(), + }; + insert(&mut s, |id| Node::new(id, NodeKind::PlacedDocument(Box::new(p)))); + let (b, end) = diamond_weeding(&mut s); + let half = 50.0 * std::f64::consts::SQRT_2; + let y = b.y0 + b.height() / 4.0; + assert!((end - (300.0 - (y - (300.0 - half)))).abs() < 0.01, "{end} at {y}"); +} + +#[test] +fn a_symbol_instance_is_measured_by_its_art() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 20.0, 40.0); + let b = rect(&mut s, 140.0, 100.0, 20.0, 40.0); + select(&mut s, &[a, b]); + let inst = id_of(&s.execute("symbol.new", &json!({"name": "Pair"})).unwrap()); + select(&mut s, &[inst]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The line runs between the two rectangles too. + assert_eq!(at(&g, 120.0), [(90.0, 100.0), (120.0, 140.0), (160.0, 170.0)]); + // An instance of a symbol the document doesn't have draws nothing, and has no outlines. + s.edit("lose the symbol", |d, _| { + if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(inst).map(|n| &mut n.kind) { + *symbol = "Missing".into(); + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[inst]); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err(); + assert!(e.to_string().contains("has no outlines"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + select(&mut s, &[inst, shown]); + let (_, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&g), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); +} + +#[test] +fn a_blend_is_measured_by_the_art_it_draws() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 20.0, 40.0); + let b = rect(&mut s, 200.0, 100.0, 20.0, 40.0); + let blend = id_of(&s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap()); + select(&mut s, &[blend]); + let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The step halfway between the two rectangles stops the line too. + assert_eq!(at(&g, 120.0), [(90.0, 100.0), (120.0, 150.0), (170.0, 200.0), (220.0, 230.0)]); +} + +#[test] +fn the_group_goes_on_top_of_the_current_layer_and_hidden_layers_are_left_out() { + let mut s = session(); + let first = s.doc().unwrap().doc.layers[0].id; + let hidden = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let art = id_of(&s.execute("layer.new", &json!({"name": "Art"})).unwrap()); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let below = rect(&mut s, 400.0, 400.0, 20.0, 20.0); + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(first) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[hidden]); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "art in a hidden layer doesn't show"); + select(&mut s, &[hidden, shown]); + let (_, g) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&g), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); + let d = &s.doc().unwrap().doc; + assert_eq!(d.parent_of(g.id), Some(art)); + assert_eq!(d.node(art).unwrap().children().unwrap().last().map(|c| c.id), Some(g.id), "on top"); + // A hidden object selected on its own shows nothing. + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(below) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[below]); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "a hidden object doesn't show"); +} + +#[test] +fn the_group_goes_outside_a_clip_group_edited_in_isolation() { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id; + let g = clip_group(&mut s, shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0))); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + let art = node(&s, g).children().unwrap()[1].id; + select(&mut s, &[art]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + // The clipping path would hide the lines and the box around the art inside it. + let d = &s.doc().unwrap().doc; + assert_eq!(d.parent_of(w.id), Some(layer)); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); + // A plain group edited in isolation inside a clip group: the group goes outside the clip group. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let (inner, art) = s + .edit("clip group around a group", |d, _| { + let clip = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default())); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0)), Appearance::default()); + let art_id = art.id; + let inner = Node::group(d.alloc_id(), vec![Arc::new(art)]); + let inner_id = inner.id; + let mut g = Node::group(d.alloc_id(), vec![clip, Arc::new(inner)]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + d.insert(parent, usize::MAX, g)?; + Ok((inner_id, art_id)) + }) + .unwrap(); + s.execute("object.isolate", &json!({"id": inner.0})).unwrap(); + select(&mut s, &[art]); + let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"})); + assert_eq!(s.doc().unwrap().doc.parent_of(w.id), parent); + assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0))); +} + +/// Adds one path of `count` open vertical subpaths 2 pt apart, from (100, 100) down to y = 200. +fn bars(s: &mut Session, count: u32) -> NodeId { + let mut p = BezPath::new(); + for i in 0..count { + let x = 100.0 + f64::from(i) * 2.0; + p.move_to((x, 100.0)); + p.line_to((x, 200.0)); + } + insert(s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default())) +} + +#[test] +fn more_than_the_most_pieces_of_line_is_refused() { + // 1,000 lines each cut into 100 pieces by 101 bars: as many as one run adds. + let mut s = session(); + bars(&mut s, 101); + let r = s.execute("addon.cutting.weed", &json!({"margin": 0, "count": 1000, "direction": "horizontal"})).unwrap(); + assert_eq!(r["lines"], json!(super::MAX_PIECES)); + // One bar more makes 1,000 pieces more. + let mut s = session(); + bars(&mut s, 102); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({"margin": 0, "count": 1000, "direction": "horizontal"})).unwrap_err(); + assert!(e.to_string().contains("more than 100000 pieces"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); +} + +#[test] +fn art_with_more_than_the_most_outline_points_is_refused() { + // An open zigzag of `points` points: one subpath, measured by its points. + let zigzag = |s: &mut Session, points: u32| { + let mut p = BezPath::new(); + p.move_to((100.0, 100.0)); + for i in 1..points { + p.line_to((100.0 + f64::from(i) * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + insert(s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default())); + }; + let mut s = session(); + zigzag(&mut s, super::geometry::MAX_POINTS as u32); + s.execute("addon.cutting.weed", &json!({"count": 0})).unwrap(); + let mut s = session(); + zigzag(&mut s, super::geometry::MAX_POINTS as u32 + 1); + let e = s.execute("addon.cutting.weed", &json!({"count": 0})).unwrap_err(); + assert!(e.to_string().contains("too many outline points"), "{e}"); +} + +#[test] +fn bad_params_and_an_empty_selection_are_refused() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + for p in [ + json!({"margin": -1}), + json!({"gap": "x"}), + json!({"count": 1.5}), + json!({"count": 1001}), + json!({"count": 2, "spacing": 10}), + json!({"spacing": 0}), + json!({"direction": "diagonal"}), + json!({"box": "yes"}), + json!({"weight": 0}), + json!({"swatch": "no such swatch"}), + json!({"swatch": 3}), + json!({"box": false, "count": 0}), + ] { + let n = s.doc().unwrap().doc.node_count(); + assert!(s.execute("addon.cutting.weed", &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n, "nothing added for {p}"); + } + // The same art with good params is weeded; with nothing selected it is refused. + s.execute("addon.cutting.weed", &json!({"count": 2, "gap": "1 mm"})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + assert!(s.execute("addon.cutting.weed", &json!({})).is_err()); +} diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 4065cc0a7c..55566c3d9b 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -33,6 +33,7 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.color.swatchLegend", "addon.contour.set", "addon.cutting.bridges", + "addon.cutting.weed", "addon.image.removeBackground", "addon.pathtools.addPoints", "addon.pathtools.close", diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 28799ae686..002f4df388 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -796,6 +796,12 @@ fn structured_junk() { ("addon.pen.reshape", json!({"id": 2, "segment": 0, "t": "x", "dx": 1, "dy": 1, "mode": "symmetric"})), ("addon.pen.nextSegment", json!({"curved": "yes"})), ("addon.pen.closeJoin", json!({"ids": [u64::MAX]})), + // Cutting Tools add-on. + ("addon.cutting.weed", json!({"margin": 1e5, "count": 1000, "gap": 1e5, "direction": "both"})), + ("addon.cutting.weed", json!({"margin": -1, "count": 1.5, "spacing": 0, "gap": f64::MAX, "weight": 1e308})), + ("addon.cutting.weed", json!({"spacing": 1e-300, "box": "no", "direction": ["both"], "swatch": 5})), + ("addon.cutting.weed", json!({"spacing": "1 mm", "box": false, "direction": "vertical", "swatch": "no such swatch"})), + ("addon.cutting.weed", json!({"count": u64::MAX, "margin": "x", "gap": null, "weight": 0})), // Type Tools add-on. ("addon.type.textTweaker", json!({"attribute": "size", "from": 1296, "to": 0.1, "shape": "random", "seed": u64::MAX})), ("addon.type.textTweaker", json!({"attribute": "tracking", "from": f64::MAX, "to": -1e308, "shape": "spike"})), diff --git a/crates/ui-egui/src/addons/cutting.rs b/crates/ui-egui/src/addons/cutting.rs index 82fd2d6e3b..1fbdcf88c2 100644 --- a/crates/ui-egui/src/addons/cutting.rs +++ b/crates/ui-egui/src/addons/cutting.rs @@ -1,6 +1,7 @@ //! Cutting Tools in the interface: its submenu (Object › Plug-ins › Cutting Tools). Stencil //! Bridges… opens the generic command dialog (`ui.paramDialog`, `dialogs::command`) on the options -//! of `addon.cutting.bridges`. +//! of `addon.cutting.bridges`. Weed Box and Lines… opens it on the options of `addon.cutting.weed`, +//! with a Preview, and runs the command on OK. A Spacing above 0 is sent instead of the Count. use serde_json::json; @@ -10,10 +11,21 @@ use crate::menus::Item; pub const BRIDGES: &str = "addon.cutting.bridges"; /// Stencil Bridges › Direction: what the command's `direction` takes, by name. pub const BRIDGE_DIRECTIONS: &[(&str, &str)] = &[("Vertical", "vertical"), ("Horizontal", "horizontal"), ("Shortest Crossing", "shortest")]; +/// Weed Box and Lines' command. +pub const WEED: &str = "addon.cutting.weed"; +/// Weed Box and Lines › Direction: what the command's `direction` takes, by name. +pub const WEED_DIRECTIONS: &[(&str, &str)] = &[("Horizontal", "horizontal"), ("Vertical", "vertical"), ("Both", "both")]; /// Object › Plug-ins › Cutting Tools. pub fn menu() -> Vec { - vec![Item::Cmd("Stencil Bridges…", BRIDGES, json!({"width": 2, "count": 1, "direction": "vertical", "keepOriginal": true}))] + vec![ + Item::Cmd("Stencil Bridges…", BRIDGES, json!({"width": 2, "count": 1, "direction": "vertical", "keepOriginal": true})), + Item::Cmd( + "Weed Box and Lines…", + WEED, + json!({"margin": 9, "box": true, "direction": "both", "count": 1, "spacing": 0, "gap": 0, "weight": 0.3, "swatch": "[Registration]"}), + ), + ] } #[cfg(test)] diff --git a/crates/ui-egui/src/addons/cutting/tests.rs b/crates/ui-egui/src/addons/cutting/tests.rs index d5620f717b..39adb0539f 100644 --- a/crates/ui-egui/src/addons/cutting/tests.rs +++ b/crates/ui-egui/src/addons/cutting/tests.rs @@ -52,3 +52,70 @@ fn stencil_bridges_opens_its_dialog_and_runs_on_ok() { assert_eq!(steps(&app), n + 1); assert!(app.ui.status.contains("no islands"), "{}", app.ui.status); } + +/// Object › Plug-ins › Cutting Tools › Weed Box and Lines… opens the command dialog on its +/// defaults with Preview off. With Preview on the canvas shows the weed box and lines while the +/// dialog is open; Cancel takes them away with no step of history, and OK keeps them as one step. +#[test] +fn weed_box_and_lines_previews_cancels_and_runs_on_ok() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node_count(); + let (start, n, nodes) = (app.session.doc().unwrap().doc.clone(), steps(&app), count(&app)); + let open = |app: &mut VectorcraftApp| { + let entries = menus::menu_entries(app); + let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Cutting Tools"] && e.label == "Weed Box and Lines…").unwrap(); + assert!(item.enabled); + let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(app, &cmd, p); + }; + + open(&mut app); + let d = app.ui.dialog.as_ref().expect("a dialog opens"); + assert_eq!((d.str("__command"), d.str("__label")), (super::WEED.to_string(), "Weed Box and Lines".to_string())); + assert_eq!((d.str("direction"), d.bool("box"), d.bool("preview")), ("both".to_string(), true, false)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Weed Box and Lines", "Margin", "Box", "Direction", "Both", "Count", "Spacing", "Gap", "Weight", "Swatch", "Preview"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + // Every field label is translated. + crate::i18n::set_current(crate::i18n::Lang::from_code("de").expect("de registered")); + let german = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + crate::i18n::set_current(crate::i18n::Lang::EN); + for s in ["Rand:", "Rahmen:", "Richtung:", "Farbfeld:"] { + assert!(german.contains(s), "{s} in {german}"); + } + assert_eq!(count(&app), nodes, "no preview without the checkbox"); + + // Preview on: the group with the box and four lines is on the canvas. + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(true)); + painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert_eq!(count(&app), nodes + 6, "previewed: a group, the box and four lines"); + + // Cancel: as it was, with no step of history. + crate::dialogs::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, start, "Cancel takes the preview away"); + assert_eq!(steps(&app), n, "and adds no undo step"); + + // OK: one step. + open(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(steps(&app), n + 1, "one undo step"); + assert_eq!(count(&app), nodes + 6); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, start); + + // A Spacing above 0 is used instead of the Count: the 68 pt wide box in strips at most 20 pt + // wide takes three vertical lines, each cut in two by the rectangle. + app.run("select.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::set_field(d, "direction".into(), json!("vertical")); + crate::dialogs::set_field(d, "spacing".into(), json!(20)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(count(&app), nodes + 8, "a group, the box and six pieces of line"); +} diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 7c9aaf6873..374868e27f 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -64,12 +64,12 @@ pub(super) const SPEC: DialogSpec = DialogSpec { /// Does this dialog show a Preview checkbox? Grid Repeat Options does: the public page for the /// grid options lists one, and the radial and mirror pages do not. Type on a Path Options does /// too (#5360), as public tutorials of the dialog show, and Type Tools › Release Type on a Path -/// works the same way (#5532), and so does Image Tools › Remove Background. (Cancel rolls a -/// preview back whichever dialog started it.) +/// works the same way (#5532), and so do Image Tools › Remove Background and Cutting Tools › Weed +/// Box and Lines. (Cancel rolls a preview back whichever dialog started it.) fn previews(d: &Dialog) -> bool { match d.str("__command").as_str() { "object.repeat.options" => d.fields.contains_key("gridType"), - "type.pathOptions" | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND => true, + "type.pathOptions" | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND | crate::addons::cutting::WEED => true, _ => false, } } @@ -87,6 +87,14 @@ fn call(d: &Dialog) -> (String, Value) { { o.remove("rowGutter"); } + // Weed Box and Lines: a Spacing above 0 divides the box instead of the Count, and only the one + // in use is sent. + if d.str("__command") == crate::addons::cutting::WEED + && let Some(o) = params.as_object_mut() + { + let by_spacing = o.get("spacing").and_then(Value::as_f64).is_some_and(|s| s > 0.0); + o.remove(if by_spacing { "count" } else { "spacing" }); + } (d.str("__command"), params) } @@ -102,6 +110,7 @@ fn lengths(command: &str) -> &'static [&'static str] { "graph.create" => &["width", "height"], "shape.flare" => &["diameter", "pathLength"], "perspective.grid.set" => &["cell", "distance"], + crate::addons::cutting::WEED => &["margin", "spacing", "gap", "weight"], "object.repeat.options" => &["radius", "hSpacing", "vSpacing"], "text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin", "lineGap"], "type.pathOptions" => &["spacing"], @@ -115,6 +124,7 @@ fn lengths(command: &str) -> &'static [&'static str] { /// names isn't it (the rest follow, by name). fn order(command: &str) -> &'static [&'static str] { match command { + crate::addons::cutting::WEED => &["margin", "box", "direction", "count", "spacing", "gap", "weight", "swatch"], // The public Trap options' order. "object.pathfinder.trap" => &["thickness", "heightWidth", "tintReduction", "trapsWithProcessColor", "reverseTraps"], "addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"], @@ -135,6 +145,7 @@ fn choices(command: &str, key: &str) -> Option { ("text.areaOptions", "fit") => Some(AREA_FIT), ("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE), ("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN), + (crate::addons::cutting::WEED, "direction") => Some(crate::addons::cutting::WEED_DIRECTIONS), ("graph.setType", "type") => Some(GRAPH_KINDS.as_slice()), ("graph.setType", "valueAxis") => Some(GRAPH_VALUE_AXIS), ("graph.setType", "tickLength" | "rightTickLength" | "categoryTickLength") => Some(GRAPH_TICK_LENGTH), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2fe391b672..c66a8c6339 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6616,4 +6616,9 @@ breakText Glyphs Глифи Stencil Bridges… Мостчета на шаблона… Stencil Bridges Мостчета на шаблона Shortest Crossing Най-късо пресичане + Weed Box and Lines… Рамка и линии за почистване… + Weed Box and Lines Рамка и линии за почистване + Margin Поле + Box Рамка + Swatch Образец Direction Посока diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 4b8b340998..a92d6cb1fa 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6667,4 +6667,9 @@ shape Open Otvoren Stencil Bridges… Mostići šablona… Stencil Bridges Mostići šablona Shortest Crossing Najkraći prijelaz + Weed Box and Lines… Okvir i linije za čišćenje… + Weed Box and Lines Okvir i linije za čišćenje + Margin Margina + Box Okvir + Swatch Uzorak boje Direction Smjer diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 86a6ad4b1a..b616bbc426 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6900,3 +6900,8 @@ breakText Glyphs Glifs @msg Some islands could not be joined to the area around the art Algunes illes no s’han pogut unir a l’àrea que envolta la il·lustració @msg `width` must be from {lo} to {hi} pt `width` ha d’estar entre {lo} i {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` ha de ser un nombre enter d’1 a {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Caixa i línies de pelat… + Weed Box and Lines Caixa i línies de pelat + Margin Marge + Box Caixa + Swatch Mostra diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5c30649715..9fdfd23102 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6919,3 +6919,8 @@ breakText Glyphs Glyfy @msg Some islands could not be joined to the area around the art Některé ostrůvky nebylo možné připojit k oblasti kolem kresby @msg `width` must be from {lo} to {hi} pt `width` musí být od {lo} do {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` musí být celé číslo od 1 do {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Rámeček a linie pro vybírání… + Weed Box and Lines Rámeček a linie pro vybírání + Margin Okraj + Box Rámeček + Swatch Vzorek diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index ac332632d7..1c9744635c 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6630,4 +6630,9 @@ breakText Glyphs Glyffer Stencil Bridges… Stencilbroer… Stencil Bridges Stencilbroer Shortest Crossing Korteste krydsning + Weed Box and Lines… Lugeramme og lugelinjer… + Weed Box and Lines Lugeramme og lugelinjer + Margin Margen + Box Ramme + Swatch Farveprøve Direction Retning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f94db9442a..2ec3b656c9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6506,3 +6506,8 @@ breakText Glyphs Glyphen @msg Some islands could not be joined to the area around the art Einige Inseln konnten nicht mit der Fläche um das Bildmaterial verbunden werden @msg `width` must be from {lo} to {hi} pt `width` muss zwischen {lo} und {hi} pt liegen @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` muss eine ganze Zahl von 1 bis {MAX_BRIDGES_PER_ISLAND} sein + Weed Box and Lines… Entgitterrahmen und -linien… + Weed Box and Lines Entgitterrahmen und -linien + Margin Rand + Box Rahmen + Swatch Farbfeld diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 34586318a0..64b1ad12e0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6871,3 +6871,8 @@ breakText Glyphs Γλύφοι @msg Some islands could not be joined to the area around the art Ορισμένες νησίδες δεν ήταν δυνατό να συνδεθούν με την περιοχή γύρω από το γραφικό @msg `width` must be from {lo} to {hi} pt Το `width` πρέπει να είναι από {lo} έως {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} Το `count` πρέπει να είναι ακέραιος από 1 έως {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Πλαίσιο και γραμμές αφαίρεσης περισσευμάτων… + Weed Box and Lines Πλαίσιο και γραμμές αφαίρεσης περισσευμάτων + Margin Περιθώριο + Box Πλαίσιο + Swatch Δείγμα χρώματος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2c85f3e751..d4215a95c2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6862,3 +6862,8 @@ breakText Glyphs Glifos @msg Some islands could not be joined to the area around the art Algunas islas no se pudieron unir al área que rodea la ilustración @msg `width` must be from {lo} to {hi} pt `width` debe estar entre {lo} y {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` debe ser un número entero entre 1 y {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Caja y líneas de pelado… + Weed Box and Lines Caja y líneas de pelado + Margin Margen + Box Caja + Swatch Muestra diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3607d3fe64..10736a8c4a 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6954,3 +6954,8 @@ breakText Glyphs Merkit @msg Some islands could not be joined to the area around the art Joitakin saarekkeita ei voitu yhdistää grafiikkaa ympäröivään alueeseen @msg `width` must be from {lo} to {hi} pt `width`-arvon on oltava {lo}–{hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` on oltava kokonaisluku 1–{MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Karsintakehys ja -viivat… + Weed Box and Lines Karsintakehys ja -viivat + Margin Marginaali + Box Kehys + Swatch Värimalli diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f0d97846f7..c86b39d20a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6914,3 +6914,8 @@ breakText Glyphs Glyphes @msg Some islands could not be joined to the area around the art Certains îlots n’ont pas pu être reliés à la zone autour de l’illustration @msg `width` must be from {lo} to {hi} pt `width` doit être compris entre {lo} et {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` doit être un nombre entier compris entre 1 et {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Cadre et lignes d’échenillage… + Weed Box and Lines Cadre et lignes d’échenillage + Margin Marge + Box Cadre + Swatch Nuance diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 2b063c2214..9f9493d4cc 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8354,3 +8354,8 @@ theme Appearance अपीयरेंस @msg Some islands could not be joined to the area around the art कुछ आइलैंड आर्टवर्क के आसपास के क्षेत्र से नहीं जोड़े जा सके @msg `width` must be from {lo} to {hi} pt `width` {lo} से {hi} pt तक होना चाहिए @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 1 से {MAX_BRIDGES_PER_ISLAND} तक पूरी संख्या होना चाहिए + Weed Box and Lines… वीड बॉक्स और लाइनें… + Weed Box and Lines वीड बॉक्स और लाइनें + Margin मार्जिन + Box बॉक्स + Swatch स्वॉच diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 23f8a096bb..eca13575c2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6903,3 +6903,8 @@ breakText Glyphs Glifák @msg Some islands could not be joined to the area around the art Egyes szigeteket nem sikerült összekötni a grafikát körülvevő területtel @msg `width` must be from {lo} to {hi} pt A `width` értéke {lo} és {hi} pt között lehet @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count`: 1 és {MAX_BRIDGES_PER_ISLAND} közötti egész szám kell + Weed Box and Lines… Gyomláló keret és vonalak… + Weed Box and Lines Gyomláló keret és vonalak + Margin Margó + Box Keret + Swatch Színminta diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index eaae2e0f7e..7669594925 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6630,4 +6630,9 @@ breakText Glyphs Glif Stencil Bridges… Jembatan Stensil… Stencil Bridges Jembatan Stensil Shortest Crossing Persilangan terpendek + Weed Box and Lines… Kotak dan garis pembersih sisa… + Weed Box and Lines Kotak dan garis pembersih sisa + Margin Margin + Box Kotak + Swatch Sampel warna Direction Arah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 773ed4a83e..bc9fb483c9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6540,3 +6540,8 @@ breakText Glyphs Glifi @msg Some islands could not be joined to the area around the art Non è stato possibile collegare alcune isole all’area intorno alla grafica @msg `width` must be from {lo} to {hi} pt `width` deve essere compreso tra {lo} e {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` deve essere un numero intero compreso tra 1 e {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Riquadro e linee di spellicolatura… + Weed Box and Lines Riquadro e linee di spellicolatura + Margin Margine + Box Riquadro + Swatch Campione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 799558a1f2..bf6cd242e3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6605,3 +6605,8 @@ breakText Glyphs 字形 @msg Some islands could not be joined to the area around the art 一部のアイランドをアートワークの周囲の領域につなげられませんでした @msg `width` must be from {lo} to {hi} pt `width` は {lo}~{hi} pt の範囲で指定してください @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` には 1 から {MAX_BRIDGES_PER_ISLAND} の整数を指定してください + Weed Box and Lines… カス取り枠とライン… + Weed Box and Lines カス取り枠とライン + Margin マージン + Box 枠 + Swatch スウォッチ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 243c79182a..56aee27b6e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -7033,3 +7033,8 @@ font-script Japanese იაპონური @msg Some islands could not be joined to the area around the art ზოგიერთი კუნძული ვერ დაუკავშირდა ნახატის გარშემო არეს @msg `width` must be from {lo} to {hi} pt `width` უნდა იყოს {lo}-დან {hi} pt-მდე @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` უნდა იყოს მთელი რიცხვი 1-დან {MAX_BRIDGES_PER_ISLAND}-მდე + Weed Box and Lines… ნარჩენის მოსაცილებელი ჩარჩო და ხაზები… + Weed Box and Lines ნარჩენის მოსაცილებელი ჩარჩო და ხაზები + Margin ზღვარი + Box ჩარჩო + Swatch ფერის ნიმუში diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2c35ed37d6..0603fa9933 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6501,3 +6501,8 @@ breakText Glyphs 글리프 @msg Some islands could not be joined to the area around the art 일부 섬을 아트워크 주변 영역에 연결할 수 없습니다 @msg `width` must be from {lo} to {hi} pt `width`는 {lo}에서 {hi}pt 사이여야 합니다 @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count`는 1에서 {MAX_BRIDGES_PER_ISLAND} 사이의 정수여야 합니다 + Weed Box and Lines… 위딩 상자 및 선… + Weed Box and Lines 위딩 상자 및 선 + Margin 여백 + Box 상자 + Swatch 견본 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 992cbfec61..c1559611fe 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6681,4 +6681,9 @@ breakText Glyphs Glyphs Stencil Bridges… Sjabloonbruggen… Stencil Bridges Sjabloonbruggen Shortest Crossing Kortste oversteek + Weed Box and Lines… Wiedkader en wiedlijnen… + Weed Box and Lines Wiedkader en wiedlijnen + Margin Marge + Box Kader + Swatch Staal Direction Richting diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4dd4d50a17..267f6a4e35 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6775,3 +6775,8 @@ breakText Glyphs Glify @msg Some islands could not be joined to the area around the art Niektórych wysp nie udało się połączyć z obszarem wokół grafiki @msg `width` must be from {lo} to {hi} pt `width` musi mieścić się w zakresie od {lo} do {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` musi być liczbą całkowitą od 1 do {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Ramka i linie do wybierania… + Weed Box and Lines Ramka i linie do wybierania + Margin Margines + Box Ramka + Swatch Próbka diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9894e8a8ea..96f759990f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6992,3 +6992,8 @@ swatch Yellow Green Verde-amarelado @msg Some islands could not be joined to the area around the art Algumas ilhas não puderam ser unidas à área ao redor da arte @msg `width` must be from {lo} to {hi} pt `width` deve estar entre {lo} e {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` deve ser um número inteiro de 1 a {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Caixa e Linhas de Descasque… + Weed Box and Lines Caixa e Linhas de Descasque + Margin Margem + Box Caixa + Swatch Amostra diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7e2539c3a8..05cee41b14 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7008,3 +7008,8 @@ shape Open Открытый @msg Some islands could not be joined to the area around the art Некоторые островки не удалось соединить с областью вокруг графики @msg `width` must be from {lo} to {hi} pt `width` должно быть от {lo} до {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` должно быть целым числом от 1 до {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Рамка и линии выборки… + Weed Box and Lines Рамка и линии выборки + Margin Поле + Box Рамка + Swatch Образец diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 57f26551d9..88c01f2350 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8399,3 +8399,8 @@ digits Hindi Hindi @msg Some islands could not be joined to the area around the art Vissa öar kunde inte förbindas med området runt teckningen @msg `width` must be from {lo} to {hi} pt `width` måste vara från {lo} till {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` måste vara ett heltal från 1 till {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Rensningsram och rensningslinjer… + Weed Box and Lines Rensningsram och rensningslinjer + Margin Marginal + Box Ram + Swatch Färgruta diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8b4a93439a..ae5fd67167 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6875,3 +6875,8 @@ breakText Glyphs Glifler @msg Some islands could not be joined to the area around the art Bazı adalar çizimin çevresindeki alana bağlanamadı @msg `width` must be from {lo} to {hi} pt `width` {lo} ile {hi} pt arasında olmalıdır @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 1 ile {MAX_BRIDGES_PER_ISLAND} arasında bir tam sayı olmalıdır + Weed Box and Lines… Ayıklama Kutusu ve Çizgileri… + Weed Box and Lines Ayıklama Kutusu ve Çizgileri + Margin Kenar Boşluğu + Box Kutu + Swatch Renk Örneği diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 101e853fb2..5561149bf8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6520,3 +6520,8 @@ breakText Glyphs Гліфи @msg Some islands could not be joined to the area around the art Деякі острівці не вдалося з’єднати з областю навколо графіки @msg `width` must be from {lo} to {hi} pt `width` має бути від {lo} до {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` має бути цілим числом від 1 до {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Рамка й лінії вибірки… + Weed Box and Lines Рамка й лінії вибірки + Margin Поле + Box Рамка + Swatch Зразок diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a0e2f448b2..a3b98d9bde 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6521,3 +6521,8 @@ breakText Glyphs Glyph @msg Some islands could not be joined to the area around the art Không thể nối một số đảo với vùng xung quanh hình vẽ @msg `width` must be from {lo} to {hi} pt `width` phải từ {lo} đến {hi} pt @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` phải là số nguyên từ 1 đến {MAX_BRIDGES_PER_ISLAND} + Weed Box and Lines… Khung và đường bóc phần thừa… + Weed Box and Lines Khung và đường bóc phần thừa + Margin Lề + Box Khung + Swatch Mẫu màu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c3ee18c67f..08a49d902b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6757,3 +6757,8 @@ breakText Glyphs 字形 @msg Some islands could not be joined to the area around the art 部分孤岛无法连接到图稿周围的区域 @msg `width` must be from {lo} to {hi} pt `width` 必须介于 {lo} 到 {hi} pt 之间 @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 必须是 1 到 {MAX_BRIDGES_PER_ISLAND} 之间的整数 + Weed Box and Lines… 除废框和除废线… + Weed Box and Lines 除废框和除废线 + Margin 边距 + Box 框 + Swatch 色板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 5ea3ca3396..fec11986ef 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6555,3 +6555,8 @@ breakText Glyphs 字符 @msg Some islands could not be joined to the area around the art 部分孤島無法連接到圖稿周圍的區域 @msg `width` must be from {lo} to {hi} pt `width` 必須介於 {lo} 到 {hi} pt 之間 @msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 必須是介於 1 到 {MAX_BRIDGES_PER_ISLAND} 的整數 + Weed Box and Lines… 除廢框與除廢線… + Weed Box and Lines 除廢框與除廢線 + Margin 邊距 + Box 框 + Swatch 色票 diff --git a/docs/mcp.md b/docs/mcp.md index ca34395dc3..46f79de9e0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9192,8 +9192,9 @@ it ends up in front), baked with `effect.expandAppearance`, then `object.pathfin ## Cutting Tools add-on -Cutting Tools is a built-in add-on that prepares art for cutting. Its command is `addon.cutting.bridges`; in the desktop -app it is Object › Plug-ins › Cutting Tools › Stencil Bridges…. +Cutting Tools is a built-in add-on that prepares art for cutting. Its commands are `addon.cutting.bridges` and +`addon.cutting.weed`; in the desktop app they are Object › Plug-ins › Cutting Tools › Stencil Bridges… and Weed Box and +Lines…. ### Stencil Bridges @@ -9244,6 +9245,43 @@ Crossing) and Keep Original. {"name":"run_command","arguments":{"command":"addon.cutting.bridges","params":{"width":3,"count":2,"direction":"shortest","keepOriginal":false}}} ``` +### Weed Box and Lines + +`addon.cutting.weed {margin?, box?, direction?, count?, spacing?, gap?, weight?, swatch?}` adds a weed box around the +selected art and weeding lines across the box, so the waste vinyl around the art lifts off in pieces +([what a weeding border is](https://help.arlon.com/what-is-a-weeding-border)). The ranges, defaults and rules below are +VectorCraft's own. The weed box is a rectangle `margin` points (0 to 100000, default 9) outside the art's bounds; +`box: false` leaves it out. The weeding lines run `"horizontal"`, `"vertical"` or `"both"` ways (default `"both"`) from +one side of the box to the other and divide it evenly: `count` lines each way (a whole number from 0 to 1000, default 1), +or with `spacing` instead, as few lines as keep them at most that far apart. A line is left out over the art's area and +within `gap` points of its outlines (0 to 100000, default 0: the lines stop at the outlines), so it ends in several +pieces, each a straight path. The lines and the box are stroked `weight` points wide (0.01 to 1000, default 0.3) in the +color swatch `swatch` (default `"[Registration]"`), with no fill. + +The art is measured by the outlines a cutter cuts. Closed paths enclose their area by their fill rule, so the hole of a +compound path filled even-odd gets lines too, and open paths are lines with no area. Type is measured by its glyph +outlines, a clip group by what shows inside its clipping path (an open clipping path clips as if closed), an image by +its frame, a placed file by the art it shows (by its box when the file can't be read), a symbol instance by its art (an +instance of a symbol the document doesn't have adds nothing) and a live object such as a blend by the art it draws. An +object selected inside a clip group is measured by what shows of it, and a clipping path selected on its own as its clip +group. Strokes, effects, hidden objects and objects in a hidden group or layer are left out. + +The result is one group named "Weeding" (the box is its member "Weed Box"), added on top of the current layer and +selected, as one undo step. While a clip group, or a group inside one, is edited in isolation, the group goes on top of +the container that holds the outermost clip group around it. With nothing selected, or with a selection that has no +outlines, the command fails. It also fails when the art's outlines, followed in straight segments, have more than +200000 points; when groups, symbol instances and live objects are nested more than 128 levels deep; when measuring a +clip group's outlines against each other takes more than 40000000 tests of one segment against another; when, for the +lines one way, more than 10000000 pairs of a line and an outline segment within `gap` of it are found, or closed +outline segments are checked for crossings with the lines more than 10000000 times; when the lines would be more than +100000 pieces; and when no line fits outside the art while `box` is false. Result: `{id, box, lines}`: the group's id, +the weed box's id (null without one) and the number of pieces of weeding line. Weed Box and Lines… opens the command +dialog with Preview; a Spacing above 0 is used instead of the Count. + +```json +{"name":"run_command","arguments":{"command":"addon.cutting.weed","params":{"margin":"0.25 in","direction":"both","spacing":"2 in","gap":1}}} +``` + ## Color Effects add-on Color Effects is a built-in add-on: its effects are colour adjustments in Effect › Color Effects, its command is diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c79f3dc9a7..b2e0e6f4be 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1943,6 +1943,10 @@ A VectorCraft addition, not a feature of the reference app (it has no command fo A VectorCraft addition, so no area score moves. Object › Plug-ins › Cutting Tools › Stencil Bridges… cuts strips of a set width (0.01 to 1000 pt, 2 by default) out of the selected art so that a stencil cut from it keeps its islands, such as the inside of an O or a hole in a shape, attached to the sheet ([stencil islands and bridges](https://en.wikipedia.org/wiki/Stencil)). Every island is joined to the sheet, directly or through another island, by the shortest crossings that fit, then gets more bridges spread around it up to the count asked for (1 to 16); bridges run vertically, horizontally or along the shortest crossing among 12 directions 15° apart. The art is measured by the area its outlines enclose (hidden objects left out), type by its glyph outlines without converting the type, and becomes one compound path with the gaps cut; the selected objects that make up the art go to a new hidden layer, Stencil Original, or are replaced, and selected objects with no outlines stay where they are. Art with no islands is left as it is with a message and no undo step, islands the cut result still encloses are counted and reported, and the run is one undo step. Tests: `addons::stencil::tests` in `vectorcraft-pathops` (an O, two holes, nested islands, curved outlines, width, direction, the shortest crossing at an angle, more bridges per island and their spacing, narrow islands, slivers, outlines that touch at a corner, the curve tolerance, open outlines, limits and the work budget), `cmd::addons::cutting::stencil::tests` in `vectorcraft-engine` (the hidden layer and undo, replacing, width with units, type, hidden group members, objects with no outlines, the result's paint, kind and place in the stacking order, no islands, narrow islands, bad params refused), `stencil_bridges_opens_its_dialog_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Stencil Bridges. +## Cutting Tools add-on: Weed Box and Lines (2026-11-12) + +A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Cutting Tools › Weed Box and Lines… (`addon.cutting.weed`) adds a weed box and weeding lines around the selected art for vinyl cutting ([what a weeding border is](https://help.arlon.com/what-is-a-weeding-border)). The rest is VectorCraft's own: the box is a rectangle at a margin around the art, and the lines run across it horizontally, vertically or both ways, by count or by spacing, and stop at the art's outlines or at a gap from them. The box and the lines are straight paths stroked in a chosen swatch ([Registration] by default), in one group named Weeding on top of the current layer, added as one undo step. The art is measured by the outlines a cutter cuts: compound paths by their fill rule, type by its glyph outlines, clip groups by what shows, images by their frames, placed files by the art they show, symbol instances by their art, and blends and other live objects by the art they draw. The dialog has Preview. Tests: `cmd::addons::cutting::weed::tests` in `vectorcraft-engine`, `weed_box_and_lines_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Weed Box and Lines. + ## Path Precision Tools add-on: Divide into Dashes (2026-11-01) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools › Divide into Dashes… cuts the selected paths into separate open paths along the curve (arc length, not straight distance): each `dash` long (0.01 to 1000000 pt, 10 by default) with `gap` between them (0 by default: the pieces touch); the gap pieces are left out unless Remove Gaps is off. With direct-selected segments (or anchors) only those runs are divided, the pattern running on across segments that follow one another, and the rest of such a path stays as pieces of its own. Fit to Corners and Ends gives every run between corners and ends a whole number of dashes, all stretched or squeezed alike so it starts and ends on a dash (a closed path without corners holds whole periods); otherwise the lengths are exact and each run's last piece is what is left. Every piece keeps the path's appearance and is selected; one undo step; at most 100000 pieces (more is refused). The dialog takes lengths in any unit and previews on the canvas. Tests: `dashes::tests` in `vectorcraft-pathops` (exact and fitted lengths, curves measured along them, picked segments on open and closed paths, the piece cap and bad patterns), `divide_into_dashes_*` in `cmd::addons::pathtools::tests` in `vectorcraft-engine` (gaps removed or kept, fitted lengths, the stroke kept, one undo step, selected segments only, bad params and too many pieces refused), `divide_into_dashes_opens_its_dialog_previews_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Path Precision Tools add-on. From f8c234e67030e5b765ccf3ba3ae02e8d79a0c5be Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:08:07 -0800 Subject: [PATCH 16/79] Type: Dynamic Text turns point type into area type with Block on Type > Dynamic Text and the Contextual Task Bar now also take point type. Turning it on makes the point type area type with Block on, in the same undo step: - Along the lines, the frame is as wide as the widest line as typed with its paragraph's indents, measured with the word gap, so that line keeps its size and stays where it was across the page by its own alignment. The other lines are set to that width. When negative indents take more than that line's width, the frame is as wide as the line, placed so the line keeps its aligned point, and Block makes the line larger. - The frame starts at the top of the text and grows down (to the left for vertical type) to hold every fitted line, around any Text Wrap objects above it, so nothing overflows. - The new frame takes the default Area Type Options and keeps only the gaps; options kept from an earlier frame do not carry over. - Point type with no line to fit (empty, or only spaces and breaks) is left as it is, and so is point type when `on` is false. Without `on`, point type counts as off, and the Type menu item is unchecked when point type comes first in the selection. A gap given with nothing it can apply to is an error. Block now subtracts a list level's indent from the room it sets each line to, as the layout does. Before, a nested list item was first set too large for its line and then made smaller on a later pass. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/typecmd.rs | 1 + crates/engine/src/cmd/typecmd/block.rs | 35 ++- crates/engine/src/cmd/typecmd/block_tests.rs | 252 ++++++++++++++++++- crates/engine/src/cmd/typemenu.rs | 173 +++++++++++-- crates/engine/src/tests/dynamic_text.rs | 10 +- crates/ui-egui/src/canvas/task_bar.rs | 4 +- crates/ui-egui/src/menus/state.rs | 6 +- crates/ui-egui/src/menus/tests.rs | 12 +- crates/ui-egui/src/tests/taskbar.rs | 16 ++ docs/mcp.md | 19 +- docs/target-app-parity.md | 1 + 11 files changed, 487 insertions(+), 42 deletions(-) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 33dad39d93..d492917bfe 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -1365,6 +1365,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod area_tests; mod block; +pub(crate) use block::needed_room; #[cfg(test)] mod block_tests; diff --git a/crates/engine/src/cmd/typecmd/block.rs b/crates/engine/src/cmd/typecmd/block.rs index 06453f4983..c10abbc965 100644 --- a/crates/engine/src/cmd/typecmd/block.rs +++ b/crates/engine/src/cmd/typecmd/block.rs @@ -89,6 +89,39 @@ fn line_widths(db: &vectorcraft_text::FontDb, t: &TextObject) -> std::collection out } +/// The room along its lines that a frame of `t`'s text needs for Block to keep the widest line as +/// typed at its present size: that line's width plus its paragraph's indents, as [`block_fit`] +/// measures them, and a margin of under half a size step so that the line is not made a step +/// smaller; with the widest line's first byte and its paragraph's index. A line whose negative +/// indents take more than its width needs its own width. None when no line has a width. +pub(crate) fn needed_room(t: &TextObject) -> Option<(f64, usize, usize)> { + let points = t.style_scale().map(|s| s.points).filter(|p| p.is_finite() && *p > 1e-12)?; + let text = t.plain_text(); + let lines = typed_lines(&text); + let widths = line_widths(vectorcraft_text::FontDb::global(), t); + let sizes = first_sizes(&t.runs, &lines); + lines + .iter() + .zip(&sizes) + .filter_map(|(l, size)| { + let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?; + let size = size * points; + if l.content == l.start || !(size.is_finite() && size > 0.0) { + return None; + } + let para = t.para_at(l.para); + let first = if l.para_start { para.first_line_indent } else { 0.0 }; + let indents = para.laid_out_left_indent() + para.right_indent + first; + // Negative indents that take more than the line's width leave the line no room at its + // size; the frame is then as wide as the line itself, and Block makes it larger. + let room = if width + indents > 1e-9 { width + indents } else { width }; + // A tenth of a point more size widens the line by 0.1 * width / size. + Some((room + 0.04 * width / size, l.start, l.para)) + }) + .filter(|r| r.0.is_finite() && r.0 > 1e-9) + .reduce(|a, b| if b.0 > a.0 { b } else { a }) +} + /// The size a line set at `size` points and `width` wide takes to fill `avail`, its width growing /// by `slope` a point of size (`width / size` when the whole line follows its size): rounded down /// to tenths and kept within [`SIZES`]. A line that fits and would not fit a tenth larger keeps its @@ -152,7 +185,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool { let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?; let para = t.para_at(l.para); let first = if l.para_start { para.first_line_indent } else { 0.0 }; - let avail = cell - para.left_indent - para.right_indent - first; + let avail = cell - para.laid_out_left_indent() - para.right_indent - first; let size = size * points; if !(size.is_finite() && size > 0.0) { return None; diff --git a/crates/engine/src/cmd/typecmd/block_tests.rs b/crates/engine/src/cmd/typecmd/block_tests.rs index ec458a0c9b..0354a630b3 100644 --- a/crates/engine/src/cmd/typecmd/block_tests.rs +++ b/crates/engine/src/cmd/typecmd/block_tests.rs @@ -188,13 +188,25 @@ fn bad_params_and_other_type_are_refused() { assert!(!text(&s, id).dynamic_text); let point = s.execute("text.create", &json!({"x": 10, "y": 500, "text": "point"})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [point]})).unwrap(); + // Off leaves point type as it is, with no undo step, and a gap needs it turned on. + let n = undo_len(&s); + assert_eq!(s.execute("type.dynamicText", &json!({"on": false})).unwrap()["ids"], json!([point])); + let e = s.execute("type.dynamicText", &json!({"on": false, "wordGap": 200})).unwrap_err().to_string(); + assert!(e.contains("need area type"), "{e}"); + assert_eq!(undo_len(&s), n); + assert!(matches!(text(&s, point).kind, TextKind::Point)); + let shape = s.execute("shape.rectangle", &json!({"x": 500, "y": 500, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [shape]})).unwrap(); let e = s.execute("type.dynamicText", &json!({})).unwrap_err().to_string(); assert!(e.contains("select type on a path or area type"), "{e}"); - // With point type selected too, only the area type changes. + // With point type selected too, both turn on, the point type as area type with Block on. s.execute("select.set", &json!({"ids": [id, point]})).unwrap(); + let n = undo_len(&s); let r = s.execute("type.dynamicText", &json!({})).unwrap(); - assert_eq!(r["ids"], json!([id])); - assert!(text(&s, id).dynamic_text && !text(&s, point).dynamic_text); + assert_eq!(r["ids"], json!([id, point])); + assert!(text(&s, id).dynamic_text && text(&s, point).dynamic_text); + assert!(matches!(text(&s, point).kind, TextKind::Area { .. })); + assert_eq!(undo_len(&s), n + 1); // A gap with only type on a path selected is an error, and nothing changes. let path = s.execute("path.create", &json!({"d": "M60 550 L340 550"})).unwrap()["id"].as_u64().unwrap(); let along = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Along"})).unwrap()["id"].as_u64().unwrap(); @@ -438,6 +450,228 @@ fn threaded_frames_are_refused_and_threading_turns_block_off() { assert!(matches!(text(&s, a).kind, TextKind::Area { .. })); } +/// 12 pt point type at (300, 100) with `extra` params for `text.create`, selected. +fn point(s: &mut Session, text: &str, extra: Value) -> u64 { + let mut p = json!({"x": 300, "y": 100, "size": 12, "text": text}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + id +} + +/// Where the first glyph of the laid-out line starting at byte `start` is in the document. +fn line_start(t: &TextObject, start: usize) -> vectorcraft_geom::Point { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let l = lay.lines.iter().find(|l| l.start == start).expect("a line starts there"); + t.xf * lay.glyphs[l.glyph_start].origin +} + +#[test] +fn point_type_becomes_area_type_with_block_at_its_widest_line() { + for justify in ["left", "center", "right"] { + let mut s = session(); + let id = point(&mut s, "Short\nA much longer line", json!({})); + s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap(); + let before = line_start(&text(&s, id), 6); + let n = undo_len(&s); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!((r["on"].clone(), r["ids"].clone()), (json!(true), json!([id]))); + assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(100.0), json!(0.0)), "the reply gives the new frame's gaps"); + assert_eq!(undo_len(&s), n + 1, "{justify}: one undo step"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Dynamic Text"); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}"); + assert_eq!(t.area.fit, AreaFit::None); + assert_block(&t); + assert_eq!(size_at(&t, 6), 12.0, "{justify}: the widest line keeps its size"); + assert!(size_at(&t, 0) > 24.0, "{justify}: the short line grows to the frame's width: {}", size_at(&t, 0)); + // Across, the widest line stays where it was; the short line above it grew, so it moved down. + let after = line_start(&t, 6); + assert!((after.x - before.x).abs() < 0.05, "{justify}: the widest line stays where it was across: {before:?} → {after:?}"); + assert!(after.y > before.y + 12.0, "{justify}"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}: the frame holds every line"); + // Edits fit it again as area type; undo brings the point type back as it was. + s.execute("edit.undo", &json!({})).unwrap(); + let back = text(&s, id); + assert!(matches!(back.kind, TextKind::Point) && !back.dynamic_text && size_at(&back, 0) == 12.0, "{justify}"); + } +} + +#[test] +fn point_type_with_gaps_indents_and_vertical_type_turns_into_block() { + let mut s = session(); + // The gaps apply to the frame point type becomes. + let id = point(&mut s, "one\ntwo words here", json!({})); + let r = s.execute("type.dynamicText", &json!({"wordGap": 300, "lineGap": 4})).unwrap(); + assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(300.0), json!(4.0))); + let t = text(&s, id); + assert!(t.dynamic_text && (t.area.word_gap, t.area.line_gap) == (300.0, 4.0)); + assert_block(&t); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // Indents: the frame takes them in, so the widest line keeps its size. + let id = point(&mut s, "Tiny\nThe widest line of all", json!({"y": 300})); + s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": 20, "firstLineIndent": 10})).unwrap(); + let before = line_start(&text(&s, id), 5); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_block(&t); + assert_eq!(size_at(&t, 5), 12.0); + assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5)); + // A nested list item: the frame takes in the level's indent too. + let id = point(&mut s, "Tiny\nThe widest item of all", json!({"y": 200})); + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "listLevel": 3})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_eq!(size_at(&t, 5), 12.0, "nested list"); + assert!(size_at(&t, 0) > 12.0); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // Mixed alignments: the widest line keeps its size and its place. + let id = point(&mut s, "Left\nThe widest line, right aligned", json!({"y": 400})); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) { + let right = vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Right, ..t.para.clone() }; + t.set_paragraph_styles(vec![t.para.clone(), right]); + } + let before = line_start(&text(&s, id), 5); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_eq!(size_at(&t, 5), 12.0, "mixed alignments"); + assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5)); + // A right-to-left list, whose indents the layout mirrors. + let id = point(&mut s, "קצר\nשורה ארוכה בהרבה מהראשונה", json!({"y": 450})); + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "rightIndent": 9})).unwrap(); + let widest = "קצר\n".len(); + let before = line_start(&text(&s, id), widest); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_eq!(size_at(&t, widest), 12.0, "right-to-left list"); + assert!((line_start(&t, widest).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, widest)); + // Point type that was area type with its own options: the new frame takes the defaults but the + // gaps. + let id = point(&mut s, "Was\nonce a frame of two columns", json!({"y": 500})); + s.execute("type.convertToAreaType", &json!({"ids": [id]})).unwrap(); + s.execute( + "text.areaOptions", + &json!({"columns": 2, "gutter": 12, "inset": 6, "verticalAlign": "bottom", "firstBaselineMin": 40, "wordGap": 150}), + ) + .unwrap(); + s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap(); + let before = line_start(&text(&s, id), 4); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + let defaults = vectorcraft_doc::AreaOptions { word_gap: 150.0, ..Default::default() }; + assert_eq!(t.area, defaults); + assert_eq!(size_at(&t, 4), 12.0, "options from an earlier frame"); + assert!((line_start(&t, 4).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 4)); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // Vertical type: the frame is as tall as the longest column and grows to the left. + let id = point(&mut s, "縦書き\nもっと長い縦書きの行", json!({"x": 600, "y": 40, "vertical": true})); + let before = line_start(&text(&s, id), "縦書き\n".len()); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.vertical && t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert_block(&t); + assert_eq!(size_at(&t, "縦書き\n".len()), 12.0); + let after = line_start(&t, "縦書き\n".len()); + assert!((after.y - before.y).abs() < 0.05, "the longest column stays where it was down the page: {before:?} → {after:?}"); + assert!(after.x < before.x - 12.0, "the short column before it grew, so it moved left: {before:?} → {after:?}"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); +} + +#[test] +fn point_type_with_negative_indents_wider_than_its_line_turns_into_block() { + // Block makes the line larger; it keeps its start, center or end by its alignment. + let ends = |t: &TextObject| { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let l = &lay.lines[0]; + ((t.xf * vectorcraft_geom::Point::new(l.x0, l.baseline)).x, (t.xf * vectorcraft_geom::Point::new(l.x1 - l.trailing, l.baseline)).x) + }; + for justify in ["left", "center", "right"] { + let mut s = session(); + let id = point(&mut s, "Short", json!({})); + s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": -200})).unwrap(); + let (b0, b1) = ends(&text(&s, id)); + let n = undo_len(&s); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "{justify}"); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}: the frame is as wide as the line"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}"); + assert!(size_at(&t, 0) > 12.0, "{justify}: Block makes the line larger"); + let (a0, a1) = ends(&t); + let kept = match justify { + "left" => (a0, b0), + "center" => ((a0 + a1) / 2.0, (b0 + b1) / 2.0), + _ => (a1, b1), + }; + assert!((kept.0 - kept.1).abs() < 0.05, "{justify}: the line keeps its aligned point: {b0},{b1} → {a0},{a1}"); + } +} + +#[test] +fn point_type_under_a_text_wrap_object_turns_into_block_that_holds_every_line() { + let mut s = session(); + let id = point(&mut s, "One\nTwo\nThree\nA much longer line than the rest", json!({})); + // A wrap object above the type, across the top of the frame it becomes. + let r = s.execute("shape.rectangle", &json!({"x": 250, "y": 80, "width": 400, "height": 40})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert!(!t.wrap.is_empty(), "the new area type takes the wrap object"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame holds every line around the wrap"); + // A wrap object deeper than any size Block sets, across the whole frame: the lines go below it. + let id = point(&mut s, "Below", json!({"x": 1000, "y": 100})); + let r = s.execute("shape.rectangle", &json!({"x": 900, "y": 80, "width": 400, "height": 9000})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && !t.wrap.is_empty()); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches past a deep wrap object"); + // An inverted wrap object far below the type holds the lines inside it: the frame reaches it. + let id = point(&mut s, "Inside", json!({"x": 1600, "y": 100})); + let r = s.execute("shape.rectangle", &json!({"x": 1550, "y": 9000, "width": 400, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + s.execute("object.textWrap.options", &json!({"invert": true})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && !t.wrap.is_empty()); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches a distant inverted wrap object"); +} + +#[test] +fn point_type_with_nothing_to_fit_is_left_as_it_is() { + let mut s = session(); + for blank in ["", " ", "\n\n"] { + let id = point(&mut s, blank, json!({})); + let before = text(&s, id); + let n = undo_len(&s); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!(r["on"], json!(true)); + assert_eq!(undo_len(&s), n, "{blank:?}: no undo step"); + assert_eq!(text(&s, id), before, "{blank:?}"); + // A gap needs type it can apply to. + let e = s.execute("type.dynamicText", &json!({"wordGap": 200})).unwrap_err().to_string(); + assert!(e.contains("need area type"), "{blank:?}: {e}"); + } + // A line far shorter than the widest one stops at the size limit, and the frame still holds it. + let id = point(&mut s, &format!(".\n{}", "w".repeat(400)), json!({"size": 6})); + s.execute("type.dynamicText", &json!({})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text); + assert_eq!(size_at(&t, 0), 1296.0); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); +} + #[test] fn long_text_fits_in_a_bounded_number_of_layouts() { let mut s = session(); @@ -451,4 +685,16 @@ fn long_text_fits_in_a_bounded_number_of_layouts() { // pass and the caches), plus the reply's own. let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 4; assert!(used <= bound, "{used} layouts"); + // Long point type: measured, fitted in its frame and fitted again at its depth, with the + // layouts that place and measure the frame. + let id = point(&mut s, &story, json!({})); + let before = vectorcraft_text::test_fonts::layouts_on_this_thread(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let used = vectorcraft_text::test_fonts::layouts_on_this_thread() - before; + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert_block(&t); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 8; + assert!(used <= bound, "point type: {used} layouts"); } diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index bb0e426c9b..78dab584b3 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, No use vectorcraft_geom::{Affine, Rect, shapes}; use super::object::choice; -use super::typecmd::{area_text, block_gaps, refresh_bounds}; +use super::typecmd::{area_text, block_gaps, needed_room, refresh_bounds}; use super::{ CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or, switch_or, targets, text_param, @@ -65,7 +65,7 @@ pub fn specs() -> Vec { "Dynamic Text", ["Type"], None, - "{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type (on omitted: the opposite of the first object's setting, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Point type in the selection is left as it is; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}", + "{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type, or turn it on for point type (on omitted: the opposite of the first object's setting, point type counting as off, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection, and no point type it turns into area type, is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Turning it on turns point type into area type with Block on, with the default Area Type Options but the gaps (options kept from an earlier frame do not carry over): the frame is as wide (along the lines) as the widest line as typed with its paragraph's indents, so that line keeps its size and sits where it was (by its own alignment), the other lines are set to that width (when negative indents take more than that line's width, the frame is as wide as the line, and Block makes it larger), and the frame then grows in depth (down, or to the left for vertical type) to hold every fitted line, around any Text Wrap objects above it; the gaps apply to it as to area type. Point type with no line to fit (empty, or only spaces and breaks) is left as it is, and so is point type when it is turned off; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}", has_dynamic_type, dynamic_text ), @@ -325,13 +325,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result { // A little slack on the right so the text doesn't rewrap. let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0); let slack = size * 0.5 + trailing; - // Slack goes where the alignment leaves room, so the text doesn't move or rewrap. - let (sl, sr) = match t.para_at(0).justify { - Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5), - Justify::Right | Justify::JustifyRight => (slack, 0.0), - Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0), - _ => (0.0, slack), - }; + let (sl, sr) = slack_sides(t.para_at(0).justify, lay.lines.first().is_some_and(|l| l.rtl), slack); let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0)); t.kind = TextKind::Area { frame: shapes::rectangle(frame) }; refresh_bounds(t); @@ -341,6 +335,107 @@ fn to_area(s: &mut Session, p: &Value) -> Result { Ok(ids_json(&ids)) } +/// How `slack` added to point type's lines in a frame is shared before and after them: where the +/// alignment leaves room, so the text doesn't move or rewrap. +fn slack_sides(justify: Justify, rtl: bool, slack: f64) -> (f64, f64) { + match justify { + Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5), + Justify::Right | Justify::JustifyRight => (slack, 0.0), + Justify::Auto if rtl => (slack, 0.0), + _ => (0.0, slack), + } +} + +/// Point type `t` turned into area type with Dynamic Text's Block on, with the default Area Type +/// Options but its gaps: the frame takes the room along the lines that keeps the widest line as +/// typed at its size ([`needed_room`]), placed so that line stays where it is by its own +/// alignment, and after the fit it takes the depth that holds every line, so nothing overflows. +/// Lines run in line space (for vertical type, down the page), and the frame is mapped to the +/// type's own space by the layout's `line_xf`. `wrap` is the Text Wrap shapes the new area type +/// takes, in the type's own space, which the fit and the depth follow. When negative indents take +/// more than the widest line's width, the frame is as wide as that line and placed so that the +/// line keeps its aligned point. None when no line has a +/// width (empty text, or only spaces and breaks), which Block leaves at its size. +fn point_to_block(t: &TextObject, wrap: &[vectorcraft_doc::WrapShape]) -> Option { + let db = vectorcraft_text::FontDb::global(); + // Options point type kept from a frame it had before (columns, inset, alignment, first + // baseline) do not carry over to the new frame. + let mut t = t.clone(); + let (word_gap, line_gap) = (t.area.word_gap, t.area.line_gap); + t.area = vectorcraft_doc::AreaOptions { word_gap, line_gap, ..Default::default() }; + t.wrap = wrap.to_vec(); + let t = &t; + // The room is measured as Block sets the lines in a frame, with the word gap. + let as_block = TextObject { dynamic_text: true, kind: TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)) }, ..t.clone() }; + let (room, widest, para_index) = needed_room(&as_block)?; + let lay = vectorcraft_text::layout(db, t); + let first = lay.lines.first()?; + let line = lay.lines.iter().find(|l| l.start == widest)?; + // Across, the widest line as it is aligned, with its paragraph's indents, which the frame holds. + let para = t.para_at(para_index); + let starts = widest == 0 || t.plain_text().as_bytes().get(widest - 1) == Some(&b'\n'); + let start_indent = para.laid_out_left_indent() + if starts { para.first_line_indent } else { 0.0 }; + // A horizontal right-to-left list paragraph has its indents mirrored, as the layout sets them. + let mirror = line.rtl && !t.vertical && para.list.is_some(); + let (left, right) = if mirror { (para.right_indent, start_indent) } else { (start_indent, para.right_indent) }; + let (lo, hi) = (line.x0 - left, line.x1 - line.trailing + right); + let top = lay.lines.iter().map(|l| l.baseline - l.ascent).fold(first.baseline - first.ascent, f64::min); + let bottom = lay.lines.iter().map(|l| l.baseline + l.descent).fold(top, f64::max); + if ![lo, hi, top, bottom, room].iter().all(|v| v.is_finite()) { + return None; + } + // Negative indents can take more than the line's width (hi below lo); the frame is then wider + // than the line with its indents, and the same split of the difference keeps the line's + // aligned point (its start, center or end) where it was when Block makes the line fill it. + let (sl, _) = slack_sides(para.justify, line.rtl, room - (hi - lo)); + let frame = |depth: f64| lay.line_xf.transform_rect_bbox(Rect::new(lo - sl, top, lo - sl + room, top + depth.max(1.0))); + let start = frame(bottom - top); + let mut out = t.clone(); + out.kind = TextKind::Area { frame: shapes::rectangle(start) }; + out.dynamic_text = true; + refresh_bounds(&mut out); + // The lines fitted in one cell from the frame's top deep enough for all of them (every line at + // the largest size Block sets), around any wrap shapes, give the depth the frame takes. + let mut probe = out.clone(); + let points = t.style_scale().map_or(1.0, |s| s.points).max(1e-12); + let deep = (bottom - top) + (lay.lines.len() as f64 + 1.0) * 3.0 * (1296.0 + out.area.line_gap.abs()) / points; + // Wrap shapes push lines on past them (an inverted one holds them inside it), so the cell also + // reaches as far again as the farthest one reaches past the frame's top. + let inv = lay.line_xf.inverse(); + let deep = deep + + wrap + .iter() + .filter_map(|w| w.path.bounds().map(|r| inv.transform_rect_bbox(r)).map(|r| (r.y1 - top).max(0.0) + 2.0 * w.wrap.offset.abs())) + .filter(|d| d.is_finite()) + .fold(0.0, f64::max); + probe.kind = TextKind::Area { frame: shapes::rectangle(frame(deep)) }; + if !wrap.is_empty() { + refresh_bounds(&mut probe); + } + let laid = vectorcraft_text::layout(db, &probe); + if laid.overflow { + return Some(out); + } + let mut grown = start; + for l in &laid.lines { + for y in [l.baseline - l.ascent, l.baseline + l.descent] { + grown = grown.union_pt(laid.line_xf * vectorcraft_geom::Point::new(l.x0, y)); + } + } + // Only the depth grows (for vertical type, the columns run on to the left), with a hundredth + // of a point to spare. + if t.vertical { + (grown.y0, grown.y1, grown.x0) = (start.y0, start.y1, grown.x0 - 0.01); + } else { + (grown.x0, grown.x1, grown.y1) = (start.x0, start.x1, grown.y1 + 0.01); + } + if grown.is_finite() && grown != start { + out.kind = TextKind::Area { frame: shapes::rectangle(grown) }; + refresh_bounds(&mut out); + } + Some(out) +} + /// (run, byte within the run) for a byte offset into the plain text. fn locate(runs: &[TextRun], byte: usize) -> Option<(usize, usize)> { let mut off = 0; @@ -542,14 +637,23 @@ fn dynamic_type(n: Option<&Node>) -> Option<&TextObject> { path_text(n).or_else(|| area_text(n)) } -/// `type.dynamicText` is enabled for a selection holding type on a path or area type, none of it -/// in a thread. +/// Type `type.dynamicText` takes: type on a path, area type, and point type, which it turns into +/// area type with Block on. +fn dynamic_target(n: Option<&Node>) -> Option<&TextObject> { + match n.map(|n| &n.kind) { + Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Point) => Some(t), + _ => dynamic_type(n), + } +} + +/// `type.dynamicText` is enabled for a selection holding type on a path, area type or point type, +/// none of it in a thread. fn has_dynamic_type(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; let st = s.active().ok_or("no document open")?; let d = &st.doc; let selected: Vec = st.selection.objects.to_vec(); - let types: Vec = text_ids(d, &selected).into_iter().filter(|id| dynamic_type(d.node(*id)).is_some()).collect(); + let types: Vec = text_ids(d, &selected).into_iter().filter(|id| dynamic_target(d.node(*id)).is_some()).collect(); if types.is_empty() { return Err("select type on a path or area type".into()); } @@ -575,32 +679,46 @@ fn threaded_ids(d: &Document, ids: &[NodeId]) -> String { /// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting, or on /// when a gap is given) on the selected type on a path and area type (Block, with its gaps), in -/// one undo step, and none when nothing changes. Turning it off keeps the sizes the text was drawn -/// at ([`bake_dynamic_text`]). +/// one undo step, and none when nothing changes. Turning it on also turns the selected point type +/// into area type with Block on ([`point_to_block`]). Turning it off keeps the sizes the text was +/// drawn at ([`bake_dynamic_text`]). fn dynamic_text(s: &mut Session, p: &Value) -> Result { const C: &str = "type.dynamicText"; let (word_gap, line_gap) = block_gaps(p, C)?; - let (ids, on, planned) = { + let (ids, on, planned, converted) = { let d = &s.doc()?.doc; - let ids: Vec = texts(s, p, C)?.into_iter().filter(|i| dynamic_type(d.node(*i)).is_some()).collect(); + let ids: Vec = texts(s, p, C)?.into_iter().filter(|i| dynamic_target(d.node(*i)).is_some()).collect(); let first = *ids.first().ok_or_else(|| bad(C, "select type on a path or area type"))?; if let Some(why) = threaded_refusal(d, &ids) { return Err(bad(C, why)); } let gap_given = word_gap.is_some() || line_gap.is_some(); - if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some()) { + let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?; + // Point type with a line to fit, which turning it on makes area type. + let converts = |id: &NodeId| on && dynamic_target(d.node(*id)).is_some_and(|t| matches!(t.kind, TextKind::Point) && needed_room(t).is_some()); + if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some() || converts(id)) { return Err(bad(C, "wordGap and lineGap need area type in the selection")); } - let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?; // Each object as the command leaves it, and only those it changes. let mut planned: Vec<(NodeId, TextObject)> = vec![]; + // The point type that becomes area type, with the gaps applied, by id. + let mut converted: Vec<(NodeId, TextObject)> = vec![]; for id in &ids { - let Some(t) = dynamic_type(d.node(*id)) else { continue }; + let Some(t) = dynamic_target(d.node(*id)) else { continue }; let mut after = t.clone(); - if matches!(t.kind, TextKind::Area { .. }) { + if matches!(t.kind, TextKind::Area { .. } | TextKind::Point) { after.area.word_gap = word_gap.unwrap_or(t.area.word_gap); after.area.line_gap = line_gap.unwrap_or(t.area.line_gap); } + if matches!(t.kind, TextKind::Point) { + // Point type has no Dynamic Text of its own: on, it becomes area type with Block + // on; off, or with no line to fit, it stays as it is. + if let Some(block) = on.then(|| point_to_block(&after, &[])).flatten() { + planned.push((*id, block)); + converted.push((*id, after)); + } + continue; + } match (on, t.dynamic_text) { (true, true) => refresh_bounds(&mut after), (true, false) => { @@ -620,7 +738,7 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result { planned.push((*id, after)); } } - (ids, on, planned) + (ids, on, planned, converted) }; if !planned.is_empty() { s.edit("Dynamic Text", |d, _| { @@ -629,6 +747,19 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result { **t = after.clone(); } } + if converted.is_empty() { + return Ok(()); + } + // The new area type takes the Text Wrap shapes above it; one that has any is fitted + // again around them, with the depth that holds every line. + for id in vectorcraft_doc::text::resolve_wrap(None, d) { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue }; + let block = converted.iter().find(|(c, _)| *c == id).and_then(|(_, src)| point_to_block(src, &t.wrap)); + match block { + Some(block) => **t = block, + None => refresh_bounds(t), + } + } Ok(()) })?; } diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index ae1a72fa08..6b221590b0 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -88,19 +88,17 @@ fn dynamic_text_refuses_bad_params_and_other_type() { assert!(err(s.execute("type.dynamicText", &json!({"on": 1}))).contains("on")); assert_eq!(undo_len(&s), before); assert!(!text(&s, id).dynamic_text); - // Point type alone: refused (area type takes Block, `typecmd::block_tests`). let point = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [point]})).unwrap(); - assert!(err(s.execute("type.dynamicText", &json!({}))).contains("select type on a path or area type")); let empty = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [empty]})).unwrap(); assert!(s.execute("type.dynamicText", &json!({})).is_err()); - // With point type selected too, only the type on a path changes. + // With point type selected too, both change in one undo step: the point type becomes area + // type with Block on (`typecmd::block_tests`). let before = undo_len(&s); s.execute("select.set", &json!({"ids": [point, id.0]})).unwrap(); let r = s.execute("type.dynamicText", &json!({"on": true})).unwrap(); - assert_eq!(r["ids"], json!([id.0])); - assert!(!text(&s, NodeId(point)).dynamic_text); + assert_eq!(r["ids"], json!([point, id.0])); + assert!(text(&s, NodeId(point)).dynamic_text && text(&s, id).dynamic_text); assert_eq!(undo_len(&s), before + 1); } diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index a7a7f0a45c..77e21e49bc 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -153,8 +153,8 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())), TextKind::OnPath { .. } => items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())), } - // Dynamic Text turns on or off for type on a path and area type (Block), as the Type menu's item does; - // left out for point type and type in a thread + // Dynamic Text turns on or off for type on a path and area type (Block), and turns point type into area + // type with Block on, as the Type menu's item does; left out for type in a thread // (). if crate::menus::enabled(app, "type.dynamicText") { items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index ae7b7bb891..8b731c95b7 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -41,9 +41,9 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { crate::panels::character::shared_text_flag(app, |t| t.vertical) == Some(id == "type.orientation.vertical") } "type.opticalMarginAlignment" => crate::panels::character::shared_text_flag(app, |t| t.optical_margin) == Some(true), - // Dynamic Text of the first selected type on a path or area type, which the item turns the - // other way. - "type.dynamicText" => selected_text(|k| !matches!(k, vectorcraft_engine::doc::TextKind::Point)).is_some_and(|t| t.dynamic_text), + // Dynamic Text of the first selected type, which the item turns the other way (point type + // counts as off: the item turns it into area type with Block on). + "type.dynamicText" => selected_text(|_| true).is_some_and(|t| t.dynamic_text && !matches!(t.kind, vectorcraft_engine::doc::TextKind::Point)), "view.outline" => app.outline_view(), "view.pixelPreview" => v.pixel_preview, "view.trimView" => v.trim_view, diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 3d1577e1be..f4dff96dd8 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -313,8 +313,8 @@ fn optical_margin_alignment_is_a_checked_type_menu_item() { assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false)); } -/// Type › Dynamic Text runs `type.dynamicText`, is enabled for type on a path and area type that -/// are in no thread, and is checked while the selected type has it on. +/// Type › Dynamic Text runs `type.dynamicText`, is enabled for point type and for type on a path +/// and area type that are in no thread, and is checked while the selected type has it on. #[test] fn dynamic_text_is_a_checked_type_menu_item() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); @@ -334,7 +334,8 @@ fn dynamic_text_is_a_checked_type_menu_item() { assert!(!enabled(&app, id)); let point = app.run("text.create", json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].clone(); app.run("select.set", json!({"ids": [point]})).unwrap(); - assert!(!enabled(&app, id), "point type"); + assert!(enabled(&app, id), "point type, which it turns into area type with Block on"); + assert_eq!(checked(&app, id, &Value::Null), Some(false), "point type"); let path = app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()["id"].clone(); let t = app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()["id"].clone(); app.run("select.set", json!({"ids": [t]})).unwrap(); @@ -356,6 +357,11 @@ fn dynamic_text_is_a_checked_type_menu_item() { assert_eq!(checked(&app, id, &Value::Null), Some(false)); app.run(id, json!({})).unwrap(); assert_eq!(checked(&app, id, &Value::Null), Some(true)); + // Point type first: unchecked, as the item turns it on. + app.run("select.set", json!({"ids": [point, area]})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(false), "point type first"); + app.run(id, json!({})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(true), "both on"); } /// Type › Dynamic Text Presets › Circle, Arch and Bow run `type.dynamicTextPreset` with their diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index d42be49ff9..47015639c8 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -232,6 +232,22 @@ fn area_type_offers_dynamic_text() { assert!(has(&texts, "Convert To Point Type") && !has(&texts, "Dynamic Text"), "{texts:?}"); } +/// Point type offers Dynamic Text beside Convert To Area Type, which turns it into area type with +/// Block on; the bar then offers what area type does. +#[test] +fn point_type_offers_dynamic_text() { + let mut app = app(); + let t = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Fit\nme in a frame"})).unwrap()); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + select(&mut app, &[t]); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Convert To Area Type") && has(&texts, "Dynamic Text"), "{texts:?}"); + click(&mut app, "Dynamic Text"); + assert!(matches!(kind(&app, t), NodeKind::Text(ref t) if t.dynamic_text && matches!(t.kind, TextKind::Area { .. }))); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Convert To Point Type") && has(&texts, "Dynamic Text"), "{texts:?}"); +} + /// The path's anchors (its first subpath). fn anchors(app: &VectorcraftApp, id: u64) -> Vec { match kind(app, id) { diff --git a/docs/mcp.md b/docs/mcp.md index aeed51157a..2c23a0b0f1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6923,7 +6923,7 @@ lines. The rest are VectorCraft's own rules: - The layout sets every paragraph with `wordGap` as its Desired word spacing (Justification…), and its Minimum or Maximum word spacing moves out to `wordGap` when `wordGap` lies outside it. The paragraphs keep their own Justification… values, which apply again when Block is off. Each character's leading is its size plus `lineGap`. A - gap given with no area type in the selection is an error. + gap given with no area type in the selection, and no point type that it turns into area type, is an error. - The sizes are written into the characters, so every export and the Character panel see them. After every edit to the text or the frame (typing, `text.editRange`, character and paragraph changes, `text.areaOptions {width}`, a frame resize) the lines are fitted again, in the edit's own undo step. The lines are then measured again, in at @@ -6932,14 +6932,27 @@ lines. The rest are VectorCraft's own rules: - An empty line, or a line of spaces, keeps its size. At 1,296 pt a line can be narrower than the frame; at 0.1 pt it can still wrap. A frame with no room along its lines changes nothing. Right-to-left lines are fitted as left-to-right ones are. -- Off keeps the sizes the text was drawn at. On replaces Auto Size and Shrink Text to Fit. Point type in the selection - is left as it is. Area type in a thread is refused with an error that names it (`text.areaOptions {fit: "block"}` +- Off keeps the sizes the text was drawn at. On replaces Auto Size and Shrink Text to Fit. Area type in a thread is refused with an error that names it (`text.areaOptions {fit: "block"}` refuses it too); threading a frame turns Block off, keeping its sizes. Without `on`, it toggles from the first object's setting, or turns on when a gap is given. A value that is not a boolean, or a gap outside its range, is an error. When nothing changes, no undo step is recorded. - Saved and opened, the native file keeps Block on with its gaps and the sizes in the characters; with Block off it keeps the sizes. +Turning it on also takes point type, in the same undo step (the Contextual Task Bar offers it beside Convert To Area +Type). [Adjust formatting and resize text](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html) +applies Dynamic Text, Block among its shapes, to existing text. VectorCraft's own rules for point type: + +- It becomes area type with Block on and the default Area Type Options, keeping only its gaps: options it kept from + an earlier frame (columns, inset, vertical alignment, first baseline) do not carry over. Along its lines, the frame + is as wide as the widest line as typed with its paragraph's indents, so that line keeps its size and stays where it + was across the page by its own alignment; the other lines are set to that width. When negative indents take more + than the widest line's width, the frame is as wide as that line, and Block makes it larger. The frame starts at the + top of the text and then grows down (to the left for vertical type) to hold every fitted line, around any Text Wrap + objects above it, so nothing overflows. +- The gaps given apply to its frame. Point type with no line to fit (empty, or only spaces and breaks) is left as it + is, and so is point type when `on` is false. Without `on`, point type counts as off. + The reply is `{on, ids, fitScale, wordGap, lineGap}` (`fitScale` as for type on a path, 1 when the first object is area type; the gaps are the first area type's). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e0cf03a3a..3ef8845ca8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1189,6 +1189,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, 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 above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). Dynamic Text on area type is Block (Type › Dynamic Text, the Contextual Task Bar, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, threaded frames cannot use it, and Convert To Point Type turns it off, keeping the sizes (`crates/engine/src/cmd/typecmd/block_tests.rs`). + Dynamic Text on point type (Type › Dynamic Text, the Contextual Task Bar; [Adjust formatting and resize text](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html) applies it to existing text) turns it into area type with Block on. VectorCraft's own rules: the frame is as wide as the widest line with its indents, so that line keeps its size and place across the page, and it grows in depth to hold every fitted line (`crates/engine/src/cmd/typecmd/block_tests.rs`). Type › Dynamic Text Presets › Circle, Arch and Bow (`type.dynamicTextPreset`) put point type or type on a path on a circular, upward-curving or downward-curving path with Dynamic Text on, with the text's position against the path (above, center, below) and its direction ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)). VectorCraft's own rules: the circle is as wide as the text and the text starts at its bottom; Arch and Bow are a 120° arc whose chord is as wide as the text; above, center and below are Align to Path's Descender, Center and Ascender (`crates/engine/src/tests/dynamic_text.rs`). The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. From 9c5a2fa2b87309916d4331afdd3f1456cb67cbcf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:37:18 -0800 Subject: [PATCH 17/79] 3D: a solid in a transparent gradient keeps the shared face order With Extrude & Bevel, Extrude & Bevel (Classic) or Inflate on a group or an object with fills and strokes, a solid whose gradient has transparent stops carries an opacity mask, and it was drawn as one whole surface in the order of the members. It could cover a solid in front of it or hide behind one it is in front of. Its faces now take their places in the one back-to-front order of every solid's faces, in runs that each carry the solid's opacity mask. A run splits only where a face drawn after it overlaps the next face. --- crates/effects/src/revolve.rs | 12 ++-- crates/effects/src/solids.rs | 88 ++++++++++++++++++++++++++---- crates/effects/src/solids/tests.rs | 80 ++++++++++++++++++++++++--- 3 files changed, 155 insertions(+), 25 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 4795e8848a..9b85185e2f 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -20,7 +20,7 @@ const MAX_PAINT_COPIES: usize = 256 << 20; /// The most face and box tests a group's transparent members' faces take together to rejoin their /// members' last runs; past them each starts a run of its own. -const MAX_OVERLAP_TESTS: usize = 1 << 22; +pub(crate) const MAX_OVERLAP_TESTS: usize = 1 << 22; /// The width of the stroke around each face that covers the seams between faces. const SEAM_WIDTH: f64 = 0.75; @@ -576,14 +576,14 @@ pub(crate) fn scoped<'a>(id: NodeId, surfaces: Vec<(&'a [Scope], Arc)>, le /// What a face can paint, to tell whether two faces overlap: the convex hull of the points and /// control points of its outline, which holds the outline, and the hull's box grown by half the /// seam stroke. -struct Coverage { - bounds: Rect, +pub(crate) struct Coverage { + pub(crate) bounds: Rect, hull: Vec, } impl Coverage { /// `None` for a face with no outline and no box. - fn of(face: &Node) -> Option { + pub(crate) fn of(face: &Node) -> Option { let points: Vec = match face.path_data() { Some(path) => path .to_bezpath() @@ -611,7 +611,7 @@ impl Coverage { /// Do the two faces' hulls, each grown by half the seam stroke, overlap? A test of the /// separating axes of their edges and of the page's axes: `true` whenever they overlap, and /// sometimes near a corner when they don't. - fn meets(&self, other: &Coverage) -> bool { + pub(crate) fn meets(&self, other: &Coverage) -> bool { fn normals(hull: &[Point]) -> impl Iterator + '_ { (0..hull.len()).filter_map(|i| { let (a, b) = (hull.get(i)?, hull.get((i + 1) % hull.len())?); @@ -658,7 +658,7 @@ fn convex_hull(mut points: Vec) -> Vec { } /// Do boxes `a` and `b` overlap with some area? -fn overlaps(a: Rect, b: Rect) -> bool { +pub(crate) fn overlaps(a: Rect, b: Rect) -> bool { a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index ff5b1c9711..4744202dd0 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -686,10 +686,11 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a /// solid from the path for the top visible fill and one from the outline of each visible stroke, /// each in its own paint. When every one is opaque with normal blending and no colored light makes -/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where -/// depths tie, as for the caps turned toward the viewer, in the order of the appearance, and with -/// caps, a shape's fill walls before the faces of its covering strokes that overlap them: -/// [`draw_order`]); +/// a surface one composite, their faces are drawn in one back-to-front order (where depths tie, as +/// for the caps turned toward the viewer, in the order of the appearance, and with caps, a shape's +/// fill walls before the faces of its covering strokes that overlap them: [`draw_order`]), the +/// faces of a solid in a gradient with transparent stops in runs that carry its opacity mask +/// ([`in_runs`]); /// otherwise each solid is a surface of its own, in that order. A member's opacity mask and /// blending mode, and those of a group it is in, apply to the surfaces of the members it holds /// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it @@ -841,19 +842,20 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect(); let (mut placed, mut faces) = (vec![], vec![]); - for (depths, s, _) in surfaces { + for (surface, (depths, s, _)) in surfaces.into_iter().enumerate() { if let NodeKind::Group { children, .. } = s.kind { placed.extend(depths); - faces.extend(children.into_iter().map(Some)); + faces.extend(children.into_iter().map(|face| Some((surface, face)))); } } let sealed = match fx.id.as_str() { @@ -862,7 +864,69 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option extrude_options(&p).cap, }; let order = draw_order(&placed, sealed); - Some(vec![Arc::new(Node::group(n.id, order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect()))]) + let faces: Vec<(usize, Arc)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect(); + let children = if masks.iter().all(Option::is_none) { faces.into_iter().map(|(_, face)| face).collect() } else { in_runs(n.id, faces, &masks) }; + Some(vec![Arc::new(Node::group(n.id, children))]) +} + +/// `faces` in their back-to-front order, each with the index of its surface, where some surfaces +/// carry an opacity mask (`masks`, by surface). A face of a surface without one goes in the order +/// on its own. The faces of a masked surface share a run, one group with the surface's mask. Such a +/// face joins its surface's last run unless a face drawn after that run overlaps it: only then does +/// the surface split, where one group can't be both behind and in front of another face. +fn in_runs(id: NodeId, faces: Vec<(usize, Arc)>, masks: &[Option>]) -> Vec> { + use crate::revolve::{Coverage, overlaps}; + struct Run { + surface: Option, + bounds: Rect, + faces: Vec>, + cover: Vec, + } + let mut runs: Vec = vec![]; + let mut last_run = vec![None; masks.len()]; + // Past this many tests a face starts a run of its own, which never draws out of order. + let mut budget = crate::revolve::MAX_OVERLAP_TESTS; + for (surface, face) in faces { + let Some(cover) = Coverage::of(&face) else { + // A face with no outline and no box overlaps nothing. + runs.push(Run { surface: None, bounds: Rect::ZERO, faces: vec![face], cover: vec![] }); + continue; + }; + if masks.get(surface).is_none_or(Option::is_none) { + runs.push(Run { surface: None, bounds: cover.bounds, faces: vec![face], cover: vec![cover] }); + continue; + } + let mut clear = |r: &Run| { + budget = budget.saturating_sub(1); + if !overlaps(r.bounds, cover.bounds) { + return budget > 0; + } + budget = budget.saturating_sub(r.cover.len()); + budget > 0 && r.cover.iter().all(|c| !overlaps(c.bounds, cover.bounds) || !c.meets(&cover)) + }; + let joins = last_run.get(surface).copied().flatten().filter(|&i: &usize| runs.get(i + 1..).is_some_and(|later| later.iter().all(&mut clear))); + match joins.and_then(|i| runs.get_mut(i)) { + Some(run) => { + run.bounds = run.bounds.union(cover.bounds); + run.faces.push(face); + run.cover.push(cover); + } + None => { + if let Some(last) = last_run.get_mut(surface) { + *last = Some(runs.len()); + } + runs.push(Run { surface: Some(surface), bounds: cover.bounds, faces: vec![face], cover: vec![cover] }); + } + } + } + runs.into_iter() + .filter_map(|mut r| { + let Some(mask) = r.surface.and_then(|s| masks.get(s)).cloned().flatten() else { return r.faces.pop() }; + let mut group = Node::group(id, r.faces); + group.extras_mut().mask = Some(mask); + Some(Arc::new(group)) + }) + .collect() } fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 248473f428..a79f209381 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -415,15 +415,16 @@ fn a_translucent_stroke_is_a_surface_of_its_own() { } /// With a light that is not white, a fill in a gradient with transparent stops is drawn with an -/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their -/// own, and the mask stays on the fill's. Under a white light the same object is one surface. +/// opacity mask: its faces still take their places in the one order of the fill's and the +/// stroke's faces, in runs that each carry the mask. Under a white light the faces carry the +/// gradient itself and no mask. #[test] -fn a_masked_surface_under_a_colored_light_stays_whole() { +fn a_masked_surface_under_a_colored_light_keeps_the_shared_face_order() { use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; let mut half = GradientStop::new(1.0, RED); half.opacity = 0.5; let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), half])); - for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] { + for (lights, masked) in [(json!({"lights": [{"color": "#ff8080"}]}), true), (json!({}), false)] { let mut n = stroked_rect(100.0); if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { f.paint = Paint::Gradient(Box::new(gradient.clone())); @@ -431,8 +432,10 @@ fn a_masked_surface_under_a_colored_light_stays_whole() { n.appearance.effects.push(fx(EXTRUDE, lights.clone())); let art = solid_art(&n).unwrap(); let drawn = art.children().unwrap(); - assert_eq!(drawn.len(), surfaces, "{lights}"); - assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}"); + assert_eq!(drawn.len(), 1, "{lights}: one order of faces"); + let runs: Vec<&Arc> = drawn[0].children().unwrap().iter().filter(|c| c.extras().mask.is_some()).collect(); + assert_eq!(!runs.is_empty(), masked, "{lights}"); + assert!(runs.iter().all(|r| r.children().unwrap().iter().all(|f| !blue(f))), "{lights}: the mask stays on the fill's faces"); } } @@ -847,7 +850,9 @@ fn classic_shading_keeps_a_gradients_transparency() { } let art = solid_art(&n).unwrap(); let surface = &art.children().unwrap()[0]; - assert_eq!(surface.extras().mask.is_some(), masked, "{end}"); + let mut has_mask = false; + surface.walk(&mut |c| has_mask |= c.extras().mask.is_some()); + assert_eq!(has_mask, masked, "{end}"); let mut opaque = true; surface.walk(&mut |c| { for item in &c.appearance.items { @@ -862,6 +867,67 @@ fn classic_shading_keeps_a_gradients_transparency() { } } +/// A group member in a gradient with transparent stops keeps the shared back-to-front order of +/// every member's faces: its faces go in runs that carry its opacity mask, and the other member's +/// faces are drawn between them where they are in front of some and behind others. Here a square +/// frame in the gradient holds a square in its hole: the frame's inner walls are behind the square +/// and its front cap is in front of the square's walls. +#[test] +fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let square = |b: &mut BezPath, x0: f64, x1: f64, clockwise: bool| { + let corners = [(x0, x0), (x1, x0), (x1, x1), (x0, x1)]; + let mut points = corners.to_vec(); + if !clockwise { + points.reverse(); + } + b.move_to(points[0]); + for p in &points[1..] { + b.line_to(*p); + } + b.close_path(); + }; + let art = |id: &str, params: &Value, end_opacity: f32| { + let mut end = GradientStop::new(1.0, Color::BLACK); + end.opacity = end_opacity; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), end])))); + let mut b = BezPath::new(); + square(&mut b, 100.0, 300.0, true); + square(&mut b, 150.0, 250.0, false); + let frame = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(gradient, Paint::None, 0.0)); + let mut b = BezPath::new(); + square(&mut b, 185.0, 215.0, true); + let inner = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0)); + let mut g = Node::group(NodeId(3), vec![Arc::new(frame), Arc::new(inner)]); + g.appearance.effects.push(fx(id, params.clone())); + solid_art(&g).unwrap_or_else(|| panic!("{id}")) + }; + // The drawing order: `M` for a group with an opacity mask, `F` for a face outside one. + fn visit(n: &Node, order: &mut String) { + if n.extras().mask.is_some() { + order.push('M'); + return; + } + if n.path_data().is_some() { + order.push('F'); + } + for c in n.children().into_iter().flatten() { + visit(c, order); + } + } + let order = |art: &Node| { + let mut order = String::new(); + visit(art, &mut order); + order + }; + for (id, params) in [(CLASSIC_EXTRUDE, json!({})), (EXTRUDE, json!({"lights": [{"color": "#ff8080"}]}))] { + assert!(!order(&art(id, ¶ms, 1.0)).contains('M'), "{id}: an opaque gradient needs no mask"); + let masked = order(&art(id, ¶ms, 0.5)); + let (first, last) = (masked.find('M').unwrap(), masked.rfind('M').unwrap()); + assert!(masked.get(first..last).is_some_and(|between| between.contains('F')), "{id}: faces between the member's runs: {masked}"); + } +} + #[test] fn classic_parameters_are_checked() { let n = classic(json!({})); From 2bb8cd0b92785fd287dc2bdada0cfd921b247f43 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:04:53 -0800 Subject: [PATCH 18/79] =?UTF-8?q?Swatch=20libraries:=20Patterns=20?= =?UTF-8?q?=E2=80=BA=20Optical=20Effects=20adds=20wave,=20moir=C3=A9=20and?= =?UTF-8?q?=20op-art=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Swatch Libraries › Patterns and the Swatches panel's library menu gain Optical Effects (`optical-effects`), a built-in pattern library of eight black tiles drawn in code: Sine Bands, Ripple Lines, Interference Rings, Offset Rings, Warped Checks, Pinched Stripes, Chevron Pulse and Radiating Lenses. The names and designs are VectorCraft's own. Each tile is 32 to 48 pt, one period of its design, with fixed sample counts (at most 600 paths and 20,000 segments), so a band that leaves one edge comes back at the same place on the opposite edge, and rings and lenses that cross an edge are kept whole for the tiling to draw on the next tile. Clicking a swatch adds its pattern and swatch once and applies it, as with Geometric Patterns (`swatch.library.add`). The menu label has a row in every catalog; docs/mcp.md, the parity doc and ROADMAP name the library. --- ROADMAP.md | 1 + crates/engine/src/cmd/patternlib.rs | 254 ++++++++++++++++++ crates/engine/src/tests/swatchlib.rs | 45 +++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + .../ui-egui/src/panels/library_panel/tests.rs | 3 +- docs/mcp.md | 7 +- docs/target-app-parity.md | 2 +- 33 files changed, 333 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b1f75b2735..af46cb5a4e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. - 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`). - 2026-11-11: Pattern libraries: Patterns › Grit Textures, a second built-in pattern library, holds eight seamless black textures (Fine Grain, Coarse Grit, Sand, Speckle, Stipple, Scratches, Sparse Flecks and Dense Flecks) on tiles of 18 to 36 pt, each drawn the same way every time; marks that reach past a tile edge continue on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged. +- 2026-11-11: Pattern libraries: Patterns › Optical Effects, a third built-in pattern library, of VectorCraft's own designs, holds eight black wave, moiré and op-art tiles (Sine Bands, Ripple Lines, Interference Rings, Offset Rings, Warped Checks, Pinched Stripes, Chevron Pulse and Radiating Lenses) of 32 to 48 pt, each drawn the same way every time; each tile is one period of its design, and art that reaches past a tile edge continues on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged. - 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923). - 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922). - 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925). diff --git a/crates/engine/src/cmd/patternlib.rs b/crates/engine/src/cmd/patternlib.rs index 8fd2a0ea5e..f69e4b889e 100644 --- a/crates/engine/src/cmd/patternlib.rs +++ b/crates/engine/src/cmd/patternlib.rs @@ -26,6 +26,7 @@ pub struct BuiltinPatternLibrary { pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = &[ BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }, BuiltinPatternLibrary { id: "grit-textures", name: "Grit Textures", make: grit_textures }, + BuiltinPatternLibrary { id: "optical-effects", name: "Optical Effects", make: optical_effects }, ]; /// The library category of the built-in pattern libraries. @@ -256,6 +257,162 @@ fn grit_textures(d: &mut Document) { }); } +/// `n` + 1 points of the curve `(t, f(t))` for `t` from `t0` to `t1`, evenly spaced. +fn curve(t0: f64, t1: f64, n: u32, f: impl Fn(f64) -> f64) -> Vec<(f64, f64)> { + (0..=n) + .map(|i| { + let t = t0 + (t1 - t0) * f64::from(i) / f64::from(n); + (t, f(t)) + }) + .collect() +} + +/// The closed band between two curves drawn from the same side to the same side: `upper` forward, +/// then `lower` back. +fn band(upper: &[(f64, f64)], lower: &[(f64, f64)]) -> PathData { + let points: Vec<(f64, f64)> = upper.iter().chain(lower.iter().rev()).copied().collect(); + polygon(&points) +} + +/// 72 corners of the circle of radius `r` about (`cx`, `cy`), turning the other way with `reverse`. +fn circle(cx: f64, cy: f64, r: f64, reverse: bool) -> SubPath { + const CORNERS: u32 = 72; + let turn = if reverse { -std::f64::consts::TAU } else { std::f64::consts::TAU }; + let points: Vec = (0..CORNERS) + .map(|i| { + let a = turn * f64::from(i) / f64::from(CORNERS); + Point::new(cx + r * a.cos(), cy + r * a.sin()) + }) + .collect(); + SubPath::polyline(&points, true) +} + +/// The ring from radius `inner` to `outer` about (`cx`, `cy`): the outer circle and the inner one +/// turning the other way, so the middle stays clear under either fill rule. An `inner` of 0 gives +/// a disk. +fn ring(cx: f64, cy: f64, inner: f64, outer: f64) -> PathData { + let mut subpaths = vec![circle(cx, cy, outer, false)]; + if inner > 0.0 { + subpaths.push(circle(cx, cy, inner, true)); + } + PathData::new(subpaths) +} + +/// A lens `2 × l` long and `2 × h` high about (`cx`, `cy`), lying along x: two half sine waves that +/// meet at its tips, turning the other way with `reverse`. +fn lens(cx: f64, cy: f64, l: f64, h: f64, reverse: bool) -> SubPath { + const STEPS: u32 = 32; + let at = |i: u32, side: f64| { + let t = f64::from(i) / f64::from(STEPS); + Point::new(cx - l + 2.0 * l * t, cy + side * h * (std::f64::consts::PI * t).sin()) + }; + let mut points: Vec = (0..=STEPS).map(|i| at(i, -1.0)).chain((1..STEPS).rev().map(|i| at(i, 1.0))).collect(); + if reverse { + points.reverse(); + } + SubPath::polyline(&points, true) +} + +/// Optical Effects: black waves, rings, warped checks, pinched stripes, chevrons and lenses on a +/// clear background. Each tile is one period of its design, so a band that leaves one edge comes +/// back at the same place on the opposite edge. Art that reaches past an edge is kept whole: the +/// tiling draws the part outside the tile on the next tile. +fn optical_effects(d: &mut Document) { + use std::f64::consts::TAU; + // Three bands 5 pt wide, 12 pt apart, each one sine wave across the tile. + let bands = (0..3u32) + .map(|k| { + let mid = move |x: f64| 6.0 + 12.0 * f64::from(k) + 3.0 * (TAU * x / 36.0).sin(); + band(&curve(0.0, 36.0, 48, |x| mid(x) - 2.5), &curve(0.0, 36.0, 48, |x| mid(x) + 2.5)) + }) + .collect(); + add(d, "Sine Bands", 36.0, 36.0, TileType::Grid, bands); + // Nine thin waves 4 pt apart, two waves across the tile, each a ninth of a wave on from the one + // above it, so the ninth leads into the first wave of the next tile down. + let lines = (0..9u32) + .map(|k| { + let (y, phase) = (2.0 + 4.0 * f64::from(k), TAU * f64::from(k) / 9.0); + let mid = move |x: f64| y + 2.5 * (TAU * x / 18.0 + phase).sin(); + band(&curve(0.0, 36.0, 72, |x| mid(x) - 0.6), &curve(0.0, 36.0, 72, |x| mid(x) + 0.6)) + }) + .collect(); + add(d, "Ripple Lines", 36.0, 36.0, TileType::Grid, lines); + // Thin rings 3 pt apart about the center and rings 3.3 pt apart about a point 4 pt to its + // right. The two sets cross, and the outer rings reach into the next tile across. + let rings = (1..=7u32) + .map(|k| (24.0, 3.0 * f64::from(k))) + .chain((1..=7u32).map(|k| (28.0, 3.3 * f64::from(k)))) + .map(|(cx, r)| ring(cx, 24.0, r - 0.6, r + 0.6)) + .collect(); + add(d, "Interference Rings", 48.0, 48.0, TileType::Grid, rings); + // Rings 2 pt and 1 pt wide by turns about the tile's corner and about its center, the center's + // starting with the narrower one. The corner's rings are the same rings at all four corners. + let mut rings = vec![]; + for (c, first) in [(0.0, 0), (20.0, 1)] { + let mut r = 0.0; + for k in 0..5 { + let width = if (k + first) % 2 == 0 { 2.0 } else { 1.0 }; + rings.push(ring(c, c, r, r + width)); + r += width + 1.2; + } + } + add(d, "Offset Rings", 40.0, 40.0, TileType::Grid, rings); + // An 8 × 8 checkerboard of 4 pt cells whose columns are bent by a sine wave down the tile and + // whose rows by one across it, each edge drawn through five points. + let warp = |x: f64, y: f64| (x + 2.0 * (TAU * y / 32.0).sin(), y + 2.0 * (TAU * x / 32.0).sin()); + let cells = (0..8u32) + .flat_map(|row| (0..8u32).map(move |col| (col, row))) + .filter(|(col, row)| (col + row) % 2 == 0) + .map(|(col, row)| { + let (x, y) = (4.0 * f64::from(col), 4.0 * f64::from(row)); + let corners = [(x, y), (x + 4.0, y), (x + 4.0, y + 4.0), (x, y + 4.0), (x, y)]; + let points: Vec<(f64, f64)> = corners + .windows(2) + .flat_map(|e| { + let (a, b) = (e[0], e[1]); + (0..4u32).map(move |i| { + let t = f64::from(i) / 4.0; + warp(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) + }) + }) + .collect(); + polygon(&points) + }) + .collect(); + add(d, "Warped Checks", 32.0, 32.0, TileType::Grid, cells); + // Six upright stripes 6 pt apart that widen from 0.8 pt to 5.6 pt and narrow again down the tile, + // each a sixth of a wave on from the one to its left. + let stripes = (0..6u32) + .map(|k| { + let (x, phase) = (3.0 + 6.0 * f64::from(k), TAU * f64::from(k) / 6.0); + let half = move |y: f64| 1.6 + 1.2 * (TAU * y / 36.0 + phase).cos(); + let side = |s: f64| -> Vec<(f64, f64)> { curve(0.0, 36.0, 48, |y| x + s * half(y)).into_iter().map(|(y, at)| (at, y)).collect() }; + band(&side(-1.0), &side(1.0)) + }) + .collect(); + add(d, "Pinched Stripes", 36.0, 36.0, TileType::Grid, stripes); + // Four zigzag bands 8 pt apart, two zigzags across the tile; the bend and the width of the bands + // rise and fall from band to band. + let chevrons = [(0.0, 3.0, 4.0), (8.0, 4.5, 2.5), (16.0, 3.0, 1.0), (24.0, 1.5, 2.5)] + .into_iter() + .map(|(y, bend, width)| { + let upper: Vec<(f64, f64)> = (0..=4u32).map(|i| (8.0 * f64::from(i), if i % 2 == 1 { y + bend } else { y })).collect(); + let lower: Vec<(f64, f64)> = upper.iter().map(|&(x, y)| (x, y + width)).collect(); + band(&upper, &lower) + }) + .collect(); + add(d, "Chevron Pulse", 32.0, 32.0, TileType::Grid, chevrons); + // Five nested lens outlines 1.5 pt wide about the center, 14 pt to 78 pt long. The longer ones + // reach across the side edges and cross the next tiles' lenses. + let lenses = (0..5u32) + .map(|k| { + let (l, h) = (7.0 + 8.0 * f64::from(k), 4.0 + 4.0 * f64::from(k)); + PathData::new(vec![lens(24.0, 24.0, l, h, false), lens(24.0, 24.0, l - 1.5, h - 1.5, true)]) + }) + .collect(); + add(d, "Radiating Lenses", 48.0, 48.0, TileType::Grid, lenses); +} + #[cfg(test)] mod tests { use super::*; @@ -326,4 +483,101 @@ mod tests { } assert!(crossing > 0, "no mark reaches past an edge"); } + + const OPTICAL: [&str; 8] = + ["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"]; + + fn optical_doc() -> Document { + let mut d = Document::new(100.0, 100.0); + optical_effects(&mut d); + d + } + + /// The paths of pattern `p`'s art, each with its fill rule. + fn paths(p: &PatternDef) -> Vec<(&PathData, vectorcraft_geom::FillRule)> { + p.art + .iter() + .map(|n| match &n.kind { + vectorcraft_doc::NodeKind::Path { path, rule, .. } => (path, *rule), + _ => panic!("{}: art that is not a path", p.name), + }) + .collect() + } + + #[test] + fn optical_effects_is_a_pattern_library_of_eight_bounded_tiles() { + let b = PATTERN_LIBRARIES.iter().find(|b| b.id == "optical-effects").expect("Optical Effects is listed"); + assert_eq!(b.name, "Optical Effects"); + let names: Vec = library_doc(b.id).unwrap().patterns.iter().map(|p| p.name.clone()).collect(); + assert_eq!(names, OPTICAL); + // Made twice, the art is the same. + let (a, again) = (optical_doc(), optical_doc()); + assert_eq!(a.patterns, again.patterns); + for p in &a.patterns { + let (w, h) = (p.width(), p.height()); + assert!((32.0..=48.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name); + assert_eq!(p.tile_type, TileType::Grid, "{}", p.name); + let paths = paths(p); + let segments: usize = paths.iter().flat_map(|(path, _)| &path.subpaths).map(SubPath::segment_count).sum(); + assert!(!paths.is_empty() && paths.len() <= 600 && segments <= 20_000, "{}: {} paths, {segments} segments", p.name, paths.len()); + for (path, _) in &paths { + let b = path.bounds().unwrap_or_else(|| panic!("{}: an empty path", p.name)); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() > 0.0 && b.height() > 0.0, "{}: {b:?}", p.name); + assert!(b.x1 > 0.0 && b.y1 > 0.0 && b.x0 < w && b.y0 < h, "{}: {b:?} does not touch the tile", p.name); + } + } + } + + /// Just either side of a tile's right and bottom edges, the tiling paints the same points: a band + /// cut off at an edge, or one that comes back at another height or width, paints one side only. + /// No path is another moved by a whole tile, which the tiling would draw twice in one place. + #[test] + fn optical_tiles_join_without_a_seam() { + const SAMPLES: u32 = 400; + const STEP: f64 = 1e-4; + for p in &optical_doc().patterns { + let (w, h) = (p.width(), p.height()); + let shapes: Vec<(BezPathFill, Rect)> = + paths(p).into_iter().map(|(path, rule)| ((path.to_bezpath(), rule), path.bounds().unwrap_or(Rect::ZERO))).collect(); + let paints = |q: Point| { + let near = Rect::new(q.x - STEP, q.y - STEP, q.x + STEP, q.y + STEP); + p.offsets_covering(near).into_iter().any(|o| { + let at = q - o; + shapes.iter().any(|((bp, rule), b)| { + let inside = b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1; + let turns = if inside { kurbo::Shape::winding(bp, at) } else { 0 }; + match rule { + vectorcraft_geom::FillRule::EvenOdd => turns % 2 != 0, + vectorcraft_geom::FillRule::NonZero => turns != 0, + } + }) + }) + }; + let (mut differ, mut painted) = (0, 0); + for i in 0..SAMPLES { + let t = (f64::from(i) + 0.37) / f64::from(SAMPLES); + let (right, bottom) = (Point::new(w, t * h), Point::new(t * w, h)); + let (dx, dy) = (kurbo::Vec2::new(STEP, 0.0), kurbo::Vec2::new(0.0, STEP)); + for (a, b) in [(right - dx, right + dx), (bottom - dy, bottom + dy)] { + let (pa, pb) = (paints(a), paints(b)); + differ += usize::from(pa != pb); + painted += usize::from(pa); + } + } + assert!(differ <= 2, "{}: {differ} of {} points across the edges differ", p.name, 2 * SAMPLES); + assert!(painted > 0, "{}: nothing crosses an edge", p.name); + let bounds: Vec = shapes.iter().map(|(_, b)| *b).collect(); + let same = |a: &Rect, b: Rect| { + (a.x0 - b.x0).abs() < 1e-9 && (a.y0 - b.y0).abs() < 1e-9 && (a.x1 - b.x1).abs() < 1e-9 && (a.y1 - b.y1).abs() < 1e-9 + }; + for b in &bounds { + for (dx, dy) in [(w, 0.0), (-w, 0.0), (0.0, h), (0.0, -h), (w, h), (w, -h), (-w, h), (-w, -h)] { + let moved = *b + kurbo::Vec2::new(dx, dy); + assert!(!bounds.iter().any(|o| same(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name); + } + } + } + } + + type BezPathFill = (vectorcraft_geom::BezPath, vectorcraft_geom::FillRule); } diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 57d6c23d06..a02fb079c9 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -806,7 +806,7 @@ fn a_grit_texture_adds_once_undoes_and_saves() { let list = run(&mut s, "swatch.library.list", json!({})); let ids: Vec<&str> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["geometric-patterns", "grit-textures"]); + assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]); let got = run(&mut s, "swatch.library.get", json!({"library": "Grit Textures"})); let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect(); assert_eq!(names, ["Fine Grain", "Coarse Grit", "Sand", "Speckle", "Stipple", "Scratches", "Sparse Flecks", "Dense Flecks"]); @@ -831,6 +831,49 @@ fn a_grit_texture_adds_once_undoes_and_saves() { assert_eq!(back.swatch("Stipple").map(|w| &w.paint), doc(&s).swatch("Stipple").map(|w| &w.paint)); } +/// Optical Effects is listed after Grit Textures, and reading it adds nothing. Clicking one of +/// its patterns brings it with its swatch and fills the selection with it, once, as one undo step +/// that also takes both away, and both stay after a save and reopen. +#[test] +fn an_optical_effect_adds_once_fills_undoes_and_saves() { + let mut s = session(); + let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count()); + let empty = counts(doc(&s)); + let list = run(&mut s, "swatch.library.list", json!({})); + let ids: Vec<&str> = + list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]); + let got = run(&mut s, "swatch.library.get", json!({"library": "Optical Effects"})); + let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect(); + assert_eq!( + names, + ["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"] + ); + assert_eq!(counts(doc(&s)), empty, "reading a library adds no pattern or swatch"); + let id = vectorcraft_doc::NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap()); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"], "apply": "fill"})); + assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Warped Checks"]), json!("Warped Checks"))); + assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1)); + let def = doc(&s).pattern("Warped Checks").expect("the pattern comes with its swatch").clone(); + assert_eq!(def.art.len(), 32, "the black cells of an 8 × 8 checkerboard"); + assert!(matches!(doc(&s).swatch("Warped Checks").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Warped Checks")); + let filled = |d: &vectorcraft_doc::Document| matches!(d.node(id).unwrap().appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Warped Checks"); + assert!(filled(doc(&s))); + let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"]})); + assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Warped Checks"]))); + assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(counts(doc(&s)), empty); + assert!(doc(&s).pattern("Warped Checks").is_none() && doc(&s).swatch("Warped Checks").is_none() && !filled(doc(&s))); + run(&mut s, "edit.redo", json!({})); + assert!(filled(doc(&s))); + let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap(); + assert_eq!(back.pattern("Warped Checks"), Some(&def)); + assert_eq!(back.swatch("Warped Checks").map(|w| &w.paint), doc(&s).swatch("Warped Checks").map(|w| &w.paint)); + assert!(filled(&back)); +} + /// A device name is saved with a `-` after it, which can be another library's own name: neither /// library replaces the other, and saving either again replaces only itself. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97e5b335d3..240444ac20 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5851,6 +5851,7 @@ theme Appearance Външен вид Ruby… Руби… Ruby Руби Geometric Guides Геометрични водачи + Optical Effects Оптични ефекти Geometric Patterns Геометрични мостри Grit Textures Зърнести текстури Ease In Плавно начало diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 75353321ff..22d1455f73 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5868,6 +5868,7 @@ mark-style Japanese Japanski Ruby… Ruby… Ruby Ruby Geometric Guides Geometrijske vodilice + Optical Effects Optički efekti Geometric Patterns Geometrijski uzorci Grit Textures Zrnaste teksture Ease In Ublaži ulaz diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 75e0b9ea20..2d6e8799d0 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2914,6 +2914,7 @@ digits Malayalam Malaiàlam Opens network connections Obre connexions de xarxa Open… Obre… Optical Òptic + Optical Effects Efectes òptics Optical Margin Alignment Alineació òptica de marges Dynamic Text Text dinàmic Dynamic Text Presets Valors predefinits de text dinàmic diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5d37ee136e..3c1170bf9d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6033,6 +6033,7 @@ mark-style Japanese Japonské Ruby… Ruby… Ruby Ruby Geometric Guides Geometrická vodítka + Optical Effects Optické efekty Geometric Patterns Geometrické vzorky Grit Textures Zrnité textury Ease In Pozvolný začátek diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 8167f80138..2a1b9d8007 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5866,6 +5866,7 @@ swatch Bright Violet Kraftig violet Ruby… Ruby… Ruby Ruby Geometric Guides Geometriske hjælpelinjer + Optical Effects Optiske effekter Geometric Patterns Geometriske mønstre Grit Textures Kornede teksturer Ease In Langsom start diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index bd4bb38b43..0805be2caf 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5622,6 +5622,7 @@ effect Soft Weich Ruby… Ruby… Ruby Ruby Geometric Guides Geometrische Hilfslinien + Optical Effects Optische Effekte Geometric Patterns Geometrische Muster Grit Textures Körnige Texturen Ease In Langsam starten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9860f8ad50..278b6cf81a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5984,6 +5984,7 @@ effect Points Σημεία Ruby… Ruby… Ruby Ruby Geometric Guides Γεωμετρικοί οδηγοί + Optical Effects Οπτικά εφέ Geometric Patterns Γεωμετρικά μοτίβα Grit Textures Κοκκώδεις υφές Ease In Ομαλή αρχή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 10c0c0bf87..f0f804e008 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5975,6 +5975,7 @@ mark-style Japanese Japonesas Ruby… Ruby… Ruby Ruby Geometric Guides Guías geométricas + Optical Effects Efectos ópticos Geometric Patterns Motivos geométricos Grit Textures Texturas granuladas Ease In Entrada suave diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7845235f65..471b98e76c 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6063,6 +6063,7 @@ swatch Bright Violet Kirkas violetti Ruby… Ruby… Ruby Ruby Geometric Guides Geometriset apuviivat + Optical Effects Optiset tehosteet Geometric Patterns Geometriset kuviot Grit Textures Rakeiset tekstuurit Ease In Hidas alku diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5e2895f9c2..fe3b98d0e8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6028,6 +6028,7 @@ theme Appearance Apparence Ruby… Ruby… Ruby Ruby Geometric Guides Repères géométriques + Optical Effects Effets optiques Geometric Patterns Motifs géométriques Grit Textures Textures granuleuses Ease In Accélération progressive diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index aca21d7eec..74fb288598 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3170,6 +3170,7 @@ antiAlias Sharp शार्प Opens network connections नेटवर्क कनेक्शन ओपन करें OpenType Features ओपन टाइप सुविधाएँ Optical ऑप्टिकल + Optical Effects ऑप्टिकल इफ़ेक्ट्स Optical Margin Alignment ऑप्टिकल मार्जिन संरेखण Dynamic Text डायनैमिक टेक्स्ट Dynamic Text Presets डायनैमिक टेक्स्ट प्रीसेट diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 45d19b0ca9..55b97d4d21 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6016,6 +6016,7 @@ imageTrace Outline Körvonal Ruby… Ruby… Ruby Ruby Geometric Guides Geometriai segédvonalak + Optical Effects Optikai effektusok Geometric Patterns Geometriai mintázatok Grit Textures Szemcsés textúrák Ease In Lassú kezdés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8e2b383e2d..a55f7699bf 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5866,6 +5866,7 @@ swatch Bright Violet Violet cerah Ruby… Ruby… Ruby Ruby Geometric Guides Panduan Geometris + Optical Effects Efek Optis Geometric Patterns Pola Geometris Grit Textures Tekstur Butiran Ease In Awal Lambat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 26e1f29651..a9ba306b9f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5656,6 +5656,7 @@ lighting Light Luce Ruby… Ruby… Ruby Ruby Geometric Guides Guide geometriche + Optical Effects Effetti ottici Geometric Patterns Motivi geometrici Grit Textures Texture granulose Ease In Inizio graduale diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 30873028e8..fab0450610 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5706,6 +5706,7 @@ lighting Light ライト Ruby… ルビ… Ruby ルビ Geometric Guides 幾何ガイド + Optical Effects オプティカル効果 Geometric Patterns 幾何パターン Grit Textures 粒状テクスチャ Ease In イーズイン diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8e9d9d049d..c8274c9b31 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5973,6 +5973,7 @@ imageTrace Outline კონტური Ruby… რუბი… Ruby რუბი Geometric Guides გეომეტრიული მიმმართველები + Optical Effects ოპტიკური ეფექტები Geometric Patterns გეომეტრიული პატერნები Grit Textures მარცვლოვანი ტექსტურები Ease In ნელი დასაწყისი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f00e6cdeba..93b4ce1bb9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5617,6 +5617,7 @@ lighting Light 광원 Ruby… 루비… Ruby 루비 Geometric Guides 기하학적 안내선 + Optical Effects 광학 효과 Geometric Patterns 기하학적 패턴 Grit Textures 입자 텍스처 Ease In 서서히 시작 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index bc00b76698..282b409a58 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5917,6 +5917,7 @@ swatch Bright Violet Felviolet Ruby… Ruby… Ruby Ruby Geometric Guides Geometrische hulplijnen + Optical Effects Optische effecten Geometric Patterns Geometrische patronen Grit Textures Korrelige texturen Ease In Langzaam beginnen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 23940fb80f..1899b72951 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5887,6 +5887,7 @@ mark-style Japanese Japońskie Ruby… Ruby… Ruby Ruby Geometric Guides Geometryczne linie pomocnicze + Optical Effects Efekty optyczne Geometric Patterns Wzorki geometryczne Grit Textures Tekstury ziarniste Ease In Łagodny początek diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 32c4b4bcc0..13eee61ed3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5948,6 +5948,7 @@ mark-style Japanese Japonesas Ruby… Ruby… Ruby Ruby Geometric Guides Guias geométricas + Optical Effects Efeitos ópticos Geometric Patterns Padrões geométricos Grit Textures Texturas granuladas Ease In Entrada suave diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 15d3c3a380..f1321b48d8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5642,6 +5642,7 @@ lighting Light Свет Ruby… Руби… Ruby Руби Geometric Guides Геометрические направляющие + Optical Effects Оптические эффекты Geometric Patterns Геометрические узоры Grit Textures Зернистые текстуры Ease In Плавное начало diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 5ffc963fad..999be89513 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6055,6 +6055,7 @@ font-script Japanese Japanska Ruby… Ruby… Ruby Ruby Geometric Guides Geometriska stödlinjer + Optical Effects Optiska effekter Geometric Patterns Geometriska mönster Grit Textures Korniga texturer Ease In Långsam start diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 86356d74ba..753f6024f6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6028,6 +6028,7 @@ axis Y Axis Y ekseni Snap to Tangent Teğete Yasla Snap to Perpendicular Dikeye Yasla Geometric Guides Geometrik Kılavuzlar + Optical Effects Optik Efektler Geometric Patterns Geometrik Desenler Grit Textures Grenli Dokular @msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ebba0f5c19..ad649f4ab0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5636,6 +5636,7 @@ lighting Light Світло Ruby… Рубі… Ruby Рубі Geometric Guides Геометричні напрямні + Optical Effects Оптичні ефекти Geometric Patterns Геометричні візерунки Grit Textures Зернисті текстури Ease In Плавний початок diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 0ec0fd4e40..93e9d93881 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5637,6 +5637,7 @@ theme Appearance Giao diện Ruby… Ruby… Ruby Ruby Geometric Guides Đường dẫn hình học + Optical Effects Hiệu ứng quang học Geometric Patterns Họa tiết hình học Grit Textures Kết cấu hạt Ease In Chậm lúc đầu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9fc1cdc4e8..45cd676f69 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5870,6 +5870,7 @@ lighting Light 光源 Ruby… 注音… Ruby 注音 Geometric Guides 几何参考线 + Optical Effects 光学效果 Geometric Patterns 几何图案 Grit Textures 颗粒纹理 Ease In 缓入 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 685f7887fb..e6393341b3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5674,6 +5674,7 @@ lighting Light 光源 Ruby… 注音… Ruby 注音 Geometric Guides 幾何參考線 + Optical Effects 光學效果 Geometric Patterns 幾何圖樣 Grit Textures 顆粒紋理 Ease In 緩入 diff --git a/crates/ui-egui/src/panels/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs index f28e7e5540..10acd4603e 100644 --- a/crates/ui-egui/src/panels/library_panel/tests.rs +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -155,9 +155,10 @@ fn find_filters_list_view_and_arrows_step_through_the_libraries() { assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none()); let first = SWATCH_IDS[0]; let last = SwatchLibraries::list(&app).pop().unwrap().id; - assert_eq!(last, "grit-textures", "gradient and then pattern libraries come after the swatch libraries"); + assert_eq!(last, "optical-effects", "gradient and then pattern libraries come after the swatch libraries"); assert_eq!(neighbour::(&app, "radial-glows", 1).as_deref(), Some("geometric-patterns")); assert_eq!(neighbour::(&app, "geometric-patterns", 1).as_deref(), Some("grit-textures")); + assert_eq!(neighbour::(&app, "grit-textures", 1).as_deref(), Some("optical-effects")); assert_eq!(neighbour::(&app, first, -1), Some(last), "wraps around"); assert_eq!(neighbour::(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1])); } diff --git a/docs/mcp.md b/docs/mcp.md index 7bf5ae9956..dd910b162a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3373,9 +3373,10 @@ in the `swatch.list` shape (`library` is an id or a name). `swatch.library.add { into the document as one undo step: a colour group's name brings the whole group, a swatch's name the swatch alone, no names the whole library; swatches the document already has (same name and paint) are reported under `existing` and not added again. `apply: "fill"|"stroke"` also applies the first one, in the same undo step (what clicking a -swatch in the library panel does). The pattern libraries Geometric Patterns (`geometric-patterns`) and Grit Textures -(`grit-textures`: seamless black grain, grit, sand, speckle, stipple, scratch and fleck tiles), category `patterns`, -hold pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does +swatch in the library panel does). The pattern libraries Geometric Patterns (`geometric-patterns`), Grit Textures +(`grit-textures`: seamless black grain, grit, sand, speckle, stipple, scratch and fleck tiles) and Optical Effects +(`optical-effects`: VectorCraft's own black wave, ring, warped-check, pinched-stripe, chevron and lens tiles), +category `patterns`, hold pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does (the same pattern the document has is reused, and a name another pattern or swatch has gets a number). `swatch.resetDefaults {replace?}` brings back the missing default swatches. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b1ceaf70ea..e6bcb23301 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1294,7 +1294,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, six gradient libraries (Organic Fades among them: soft, perceptually mixed multi-stop fades that thin out to transparent) and two pattern libraries (Geometric Patterns, and Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. + - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, six gradient libraries (Organic Fades among them: soft, perceptually mixed multi-stop fades that thin out to transparent) and three pattern libraries (Geometric Patterns; Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; and Optical Effects with eight of VectorCraft's own seamless wave, ring, warped-check, stripe, chevron and lens tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. From 1d7690029f7d60bcf1586871428bff31d754bb1f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:59:50 -0800 Subject: [PATCH 19/79] Document Info: Spot Color Objects lists the objects that use spot colors The spotColors category is now titled Spot Color Objects and lists each object filled or stroked with a spot color, by name with the spot colors it uses, instead of the spot swatch names. A solid color or a gradient stop linked to a spot swatch at a tint above 0 counts; process colors that look the same and Registration do not. Paint that draws nothing is left out by the rules Pattern Objects and Gradient Objects follow, and type whose characters use a spot color is listed. Objects counts the listed objects as Spot Color Objects. document.info returns spotObjects, spotColorNames and objects.spots; spotColors now holds the spot colors those objects use. The report that Document Info > Save... and Package write lists the same rows. The Spot Color Objects, Pattern Objects and Gradient Objects titles have a row in each of the 27 complete catalogs, and a test checks that every complete catalog has the three. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/docinfo.rs | 277 +++++++++++++++++++++----- crates/engine/src/tests/package.rs | 2 +- crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tests.rs | 22 ++ crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/panels/doc_info.rs | 15 +- docs/mcp.md | 7 +- docs/target-app-parity.md | 1 + 33 files changed, 352 insertions(+), 53 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index ff1fbe3591..3d2f554dc8 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in a gradient's stops, at a tint above 0, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", has_doc, info ), @@ -40,7 +40,7 @@ pub const CATEGORIES: [(&str, &str); 13] = [ ("objects", "Objects"), ("graphicStyles", "Graphic Styles"), ("brushes", "Brushes"), - ("spotColors", "Spot Colors"), + ("spotColors", "Spot Color Objects"), ("patterns", "Pattern Objects"), ("gradients", "Gradient Objects"), ("symbols", "Symbols"), @@ -52,7 +52,7 @@ pub const CATEGORIES: [(&str, &str); 13] = [ ]; /// The object counts of `document.info` and their labels. -const OBJECT_LABELS: [(&str, &str); 18] = [ +const OBJECT_LABELS: [(&str, &str); 19] = [ ("paths", "Paths"), ("compoundPaths", "Compound Paths"), ("groups", "Groups"), @@ -63,6 +63,7 @@ const OBJECT_LABELS: [(&str, &str); 18] = [ ("symbolInstances", "Symbol Instances"), ("gradients", "Gradient Objects"), ("patterns", "Pattern Objects"), + ("spots", "Spot Color Objects"), ("meshes", "Gradient Meshes"), ("blends", "Blends"), ("envelopes", "Envelopes"), @@ -131,7 +132,7 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S /// The Document Info categories of `d` (only `category` when given) from `info` (what /// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the -/// rows of its objects filled or stroked with a pattern and with a gradient. +/// rows of its objects filled or stroked with a spot color, with a pattern and with a gradient. #[allow(clippy::too_many_arguments)] fn sections( d: &Document, @@ -139,6 +140,7 @@ fn sections( fonts: &BTreeSet, images: &[ImageRow], repeats: &[(String, String)], + spot_objects: &[(String, String)], pattern_objects: &[(String, String)], gradient_objects: &[(String, String)], category: Option<&str>, @@ -183,7 +185,7 @@ fn sections( } "graphicStyles" => names("graphicStyleNames"), "brushes" => names("brushNames"), - "spotColors" => names("spotColors"), + "spotColors" => spot_objects.to_vec(), "patterns" => pattern_objects.to_vec(), "gradients" => gradient_objects.to_vec(), "symbols" => names("symbols"), @@ -273,15 +275,50 @@ fn gradient_label(p: &Paint) -> Option { } } -/// The type object `n` holds and the runs its layout draws, when it uses a pattern or a gradient: -/// the runs with a glyph outline (list markers and ruby included) or an underline or strikethrough -/// bar. Spaces and line breaks draw no outline. A type object's characters carry their own fill and -/// stroke, and the type object's own fills and strokes paint on its characters, so only those runs -/// paint. The type is laid out with its inline graphics' bounds resolved from `d`'s symbols, as the -/// renderer lays it out. -fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet)> { +/// The names of `d`'s spot color swatches: the swatches whose prints go to a plate of their own +/// ([`vectorcraft_doc::inks::inks`]). A name held by several swatches is the first one's, as +/// [`Document::swatch`] finds it, and the Registration swatch prints on every plate, so it is not +/// one. +fn spot_swatches(d: &Document) -> HashSet<&str> { + let mut first: BTreeMap<&str, bool> = BTreeMap::new(); + for s in d.swatches_iter() { + first.entry(s.name.as_str()).or_insert(s.spot && s.paint.color().is_some()); + } + first.into_iter().filter(|&(name, spot)| spot && name != vectorcraft_color::swatch::REGISTRATION).map(|(name, _)| name).collect() +} + +/// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is +/// linked to, or those a gradient's stops are linked to, each once, in the order of the stops. A +/// link at a tint of 0 prints no ink, and a color that only looks like a spot color is not linked +/// to one. +fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec { + let mut out: Vec = vec![]; + let mut add = |swatch: &Option, tint: f32| { + if let Some(name) = swatch + && tint > 0.0 + && spots.contains(name.as_str()) + && !out.contains(name) + { + out.push(name.clone()); + } + }; + match p { + Paint::Solid { swatch, tint, .. } => add(swatch, *tint), + Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| add(&s.swatch, s.tint)), + _ => {} + } + out +} + +/// The type object `n` holds and the runs its layout draws, when it uses a spot color, a pattern or +/// a gradient (`spots`: [`spot_swatches`]): the runs with a glyph outline (list markers and ruby +/// included) or an underline or strikethrough bar. Spaces and line breaks draw no outline. A type +/// object's characters carry their own fill and stroke, and the type object's own fills and strokes +/// paint on its characters, so only those runs paint. The type is laid out with its inline +/// graphics' bounds resolved from `d`'s symbols, as the renderer lays it out. +fn painted_runs<'a>(d: &Document, spots: &HashSet<&str>, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet)> { let NodeKind::Text(t) = &n.kind else { return None }; - let is_listed = |p: &Paint| matches!(p, Paint::Pattern { .. } | Paint::Gradient(_)); + let is_listed = |p: &Paint| matches!(p, Paint::Pattern { .. } | Paint::Gradient(_)) || !spot_labels(spots, p).is_empty(); let own = n.appearance.items.iter().any(|i| match i { vectorcraft_doc::AppearanceItem::Fill(f) => is_listed(&f.paint), vectorcraft_doc::AppearanceItem::Stroke(s) => is_listed(&s.paint), @@ -297,43 +334,53 @@ fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::T Some((t, painted)) } -/// The patterns or gradients (`label`: [`pattern_label`] or [`gradient_label`]) that `n`'s own -/// fills and strokes paint, each once. A hidden fill or stroke and a stroke with no width paint -/// nothing, and neither does any fill or stroke of a guide (drawn in the guide color), of a compound -/// path's member (`members`: the compound path's own fills and strokes paint it) or of type whose -/// layout draws nothing (`painted_runs`). An image's fills and strokes paint only where an effect -/// gives them a shape. +/// The names a paint is listed by in a Document Info category: its spot colors, its pattern or its +/// gradient. +type PaintNames<'a> = dyn Fn(&Paint) -> Vec + 'a; + +/// The spot colors, patterns or gradients (`label`: [`spot_labels`], [`pattern_label`] or +/// [`gradient_label`], each paint's names) that `n`'s own fills and strokes paint, each once. A +/// hidden fill or stroke and a stroke with no width paint nothing, and neither does any fill or +/// stroke of a guide (drawn in the guide color), of a compound path's member (`members`: the +/// compound path's own fills and strokes paint it) or of type whose layout draws nothing +/// (`painted_runs`). An image's fills and strokes paint only where an effect gives them a shape. fn own_paints( d: &Document, n: &Node, members: &HashSet<*const Node>, painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet)>, - label: fn(&Paint) -> Option, + label: &PaintNames<'_>, ) -> Vec { if matches!(n.kind, NodeKind::Path { guide: true, .. }) || members.contains(&std::ptr::from_ref(n)) || painted_runs.is_some_and(|(_, r)| r.is_empty()) - || !n.appearance.items.iter().any(|i| label(i.paint()).is_some()) + || n.appearance.items.iter().all(|i| label(i.paint()).is_empty()) { return vec![]; } - // The patterns or gradients an image's shaped fills and strokes paint (their effects can - // transform the paint, so they are matched by label). + // The names an image's shaped fills and strokes paint (their effects can transform the paint, + // so they are matched by name). let shaped: Option> = matches!(n.kind, NodeKind::Image(_)) - .then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().filter_map(|(_, _, item)| label(item.paint())).collect()); + .then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().flat_map(|(_, _, item)| label(item.paint())).collect()); let mut out: Vec = vec![]; for item in &n.appearance.items { - let Some(name) = label(item.paint()) else { continue }; - let paints = item.visible() - && !matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) - && shaped.as_ref().is_none_or(|s| s.contains(&name)); - if paints && !out.contains(&name) { - out.push(name); + if !item.visible() || matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) { + continue; + } + for name in label(item.paint()) { + if shaped.as_ref().is_none_or(|s| s.contains(&name)) && !out.contains(&name) { + out.push(name); + } } } out } +/// [`pattern_label`] or [`gradient_label`] as the names [`own_paints`] takes. +fn one_label(label: fn(&Paint) -> Option) -> impl Fn(&Paint) -> Vec { + move |p| label(p).into_iter().collect() +} + fn info(s: &mut Session, p: &Value) -> Result { const C: &str = "document.info"; let category = str_param(p, "category"); @@ -370,7 +417,13 @@ fn info(s: &mut Session, p: &Value) -> Result { }; let mut patterns = BTreeSet::new(); let mut symbols = BTreeSet::new(); + // `spot_objects`, `spot_rows` and `spot` hold the `spotObjects` entries, the rows of the objects filled or stroked with a spot + // color, in walk order, and the spot colors they use. `spot_swatches` names the document's spot colors once, for every paint. + let spot_swatches = spot_swatches(d); + let mut spot_objects: Vec = vec![]; + let mut spot_rows: Vec<(String, String)> = vec![]; let mut spot = BTreeSet::new(); + let (spot_label, pattern, gradient) = (|p: &Paint| spot_labels(&spot_swatches, p), one_label(pattern_label), one_label(gradient_label)); let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() }; // `brushes` holds the Brushes panel's brushes, or with selectionOnly it holds the brushes that the selected objects' strokes use. let mut brushes: Vec = if selection_only { vec![] } else { vectorcraft_brush::library(d).into_iter().map(|b| b.name).collect() }; @@ -446,17 +499,11 @@ fn info(s: &mut Session, p: &Value) -> Result { if let Some(k) = k { *counts.entry(k).or_default() += 1; } - for item in &n.appearance.items { - if let Paint::Solid { swatch: Some(name), .. } = item.paint() - && d.swatch(name).is_some_and(|s| s.spot) - { - spot.insert(name.clone()); - } - } - // The patterns and the gradients this object's fills and strokes paint, each once. - let painted_runs = painted_runs(d, n); - let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), pattern_label); - let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), gradient_label); + // The spot colors, the patterns and the gradients this object's fills and strokes paint, each once. + let painted_runs = painted_runs(d, &spot_swatches, n); + let mut used_spots = own_paints(d, n, &members, painted_runs.as_ref(), &spot_label); + let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), &pattern); + let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), &gradient); // The characters' own paints: an inline graphic draws no outline and does not take its // run's paint (a list marker set in its run does), and a stroke with no width paints // nothing. @@ -464,19 +511,26 @@ fn info(s: &mut Session, p: &Value) -> Result { for run in painted.iter().filter_map(|i| t.runs.get(*i)) { let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke); for paint in std::iter::once(&run.style.fill).chain(stroke) { - for (used, label) in - [(&mut used_patterns, pattern_label as fn(&Paint) -> Option), (&mut used_gradients, gradient_label)] - { - if let Some(name) = label(paint) - && !used.contains(&name) - { - used.push(name); + let labels: [(&mut Vec, &PaintNames<'_>); 3] = + [(&mut used_spots, &spot_label), (&mut used_patterns, &pattern), (&mut used_gradients, &gradient)]; + for (used, label) in labels { + for name in label(paint) { + if !used.contains(&name) { + used.push(name); + } } } } } } - // Objects counts the objects that Pattern Objects and Gradient Objects list. + // Objects counts the objects that Spot Color Objects, Pattern Objects and Gradient Objects list. + if !used_spots.is_empty() { + let name = n.display_name(); + spot.extend(used_spots.iter().cloned()); + spot_rows.push((name.clone(), used_spots.join(", "))); + spot_objects.push(json!({ "id": n.id.0, "name": name, "spotColors": used_spots })); + *counts.entry("spots").or_default() += 1; + } if !used_patterns.is_empty() { let name = n.display_name(); patterns.extend(used_patterns.iter().cloned()); @@ -529,6 +583,8 @@ fn info(s: &mut Session, p: &Value) -> Result { "images": images, "symbols": symbols, "spotColors": spot, + "spotColorNames": spot, + "spotObjects": spot_objects, "swatches": d.swatches_iter().count(), "graphicStyles": d.graphic_styles.len(), "graphicStyleNames": styles, @@ -544,7 +600,7 @@ fn info(s: &mut Session, p: &Value) -> Result { }); // The document's name: its file's, else its title. out["document"]["name"] = json!(st.title()); - let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, &gradient_rows, category); + let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category); if text { return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) })); } @@ -997,6 +1053,127 @@ mod tests { assert!(!text.contains("Spare"), "{text}"); } + /// Document Info › Spot Color Objects lists each object filled or stroked with a spot color once, + /// with the spot colors it uses, as a solid color or in a gradient's stops, one in a group and a + /// type object whose characters use one included. A spot swatch no art uses adds no row; a + /// process color that looks the same, Registration and a tint of 0 are not spot colors; paint + /// that draws nothing is left out by the rules Pattern Objects follows; and Objects counts the + /// listed objects. With Selection Only it lists the objects in the selection, each once, and the + /// report writes the same rows. + #[test] + fn info_lists_the_spot_color_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + // An empty document lists none. + let i = s.execute("document.info", &json!({"category": "spotColors"})).unwrap(); + assert_eq!((i["spotObjects"].clone(), i["spotColorNames"].clone(), i["spotColors"].clone()), (json!([]), json!([]), json!([]))); + assert!(i["objects"]["spots"].is_null() && i["sections"][0]["rows"] == json!([]), "{i}"); + let text = s.execute("document.info", &json!({"category": "spotColors", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nSPOT COLOR OBJECTS\nNone\n"), "{text}"); + let ink = |c: f64| json!({"model": "cmyk", "c": c, "m": 0.5, "y": 0.0, "k": 0.0}); + s.execute("swatch.new", &json!({"name": "Ink", "color": ink(1.0), "spot": true})).unwrap(); + s.execute("swatch.new", &json!({"name": "Gold", "color": ink(0.2), "spot": true})).unwrap(); + s.execute("swatch.new", &json!({"name": "Spare", "color": ink(0.4), "spot": true})).unwrap(); + // A global process color of the same color as Ink. + s.execute("swatch.new", &json!({"name": "Look", "color": ink(1.0), "global": true})).unwrap(); + let square = + |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + let ramp = |a: &str, b: &str| json!({"kind": "linear", "stops": [{"offset": 0, "color": "#000", "swatch": a}, {"offset": 1, "color": "#fff", "swatch": b}]}); + let (a, b, c, f, g, h) = + (square(&mut s, 0.0), square(&mut s, 20.0), square(&mut s, 40.0), square(&mut s, 60.0), square(&mut s, 80.0), square(&mut s, 100.0)); + s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Ink"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [a], "swatch": "Gold", "tint": 40})).unwrap(); + s.execute("layer.setProps", &json!({"id": a, "name": "Both"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [b], "swatch": "Look"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [b], "swatch": "Ink", "tint": 50})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [c], "swatch": "Ink", "tint": 0})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [f], "gradient": ramp("Gold", "Look")})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [g], "gradient": ramp("Look", "Look")})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [h], "swatch": "[Registration]"})).unwrap(); + for id in [&c, &g, &h] { + s.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap(); + } + s.execute("select.set", &json!({"ids": [b, c]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].clone(); + // A type object whose characters are filled with a spot color. + let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [t], "swatch": "Ink"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [t], "none": true})).unwrap(); + s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap(); + // A hidden spot fill and a spot stroke with no width paint nothing, so neither object is listed. + let (d, e) = (square(&mut s, 120.0), square(&mut s, 140.0)); + s.execute("paint.setStroke", &json!({"ids": [d], "none": true})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [d], "swatch": "Ink"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [d], "index": 0, "visible": false})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [e], "none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [e], "swatch": "Ink"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap(); + // Type with no characters, or of only spaces and a line break, paints nothing, and neither + // does a guide made from a path with a spot color. + let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Ink"})).unwrap(); + s.execute("appearance.addFill", &json!({"ids": [empty]})).unwrap(); + let blank = s.execute("text.create", &json!({"x": 0, "y": 320, "text": " \n"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [blank], "swatch": "Ink"})).unwrap(); + let guide = square(&mut s, 300.0); + s.execute("paint.setFill", &json!({"ids": [guide], "swatch": "Ink"})).unwrap(); + s.execute("select.set", &json!({"ids": [guide]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + // Nor does a compound path's member's own spot fill, or an image's spot fill without an + // effect that gives it a shape. + let (m1, m2) = (square(&mut s, 320.0), square(&mut s, 340.0)); + s.execute("select.set", &json!({"ids": [m1, m2]})).unwrap(); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [compound], "color": "#000000"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [compound], "none": true})).unwrap(); + let node = |s: &Session, id: &Value| s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().clone(); + let (member, paint) = (node(&s, &compound).children().unwrap()[0].id, node(&s, &a).appearance); + let pic = square(&mut s, 360.0); + s.execute("select.set", &json!({"ids": [pic]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); + let image = s.doc().unwrap().selection.objects[0]; + s.edit("own paint", |d, _| { + d.node_mut(member).unwrap().appearance = paint.clone(); + d.node_mut(image).unwrap().appearance = paint; + Ok(()) + }) + .unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + assert_eq!( + i["spotObjects"], + json!([ + {"id": a, "name": "Both", "spotColors": ["Ink", "Gold"]}, + {"id": b, "name": "", "spotColors": ["Ink"]}, + {"id": f, "name": "", "spotColors": ["Gold"]}, + {"id": t, "name": "Type", "spotColors": ["Ink"]}, + ]) + ); + assert_eq!((i["spotColorNames"].clone(), i["spotColors"].clone()), (json!(["Gold", "Ink"]), json!(["Gold", "Ink"]))); + // Objects counts the objects that Spot Color Objects lists. + assert_eq!(i["objects"]["spots"].as_u64(), Some(4), "{}", i["objects"]); + let objects = s.execute("document.info", &json!({"category": "objects"})).unwrap()["sections"][0]["rows"].clone(); + assert!(objects.as_array().unwrap().contains(&json!(["Spot Color Objects", "4"])), "{objects}"); + s.execute("select.set", &json!({"ids": [c, g, h, d, e, empty, blank, guide, compound, image.0]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap(); + assert!(i["objects"]["spots"].is_null() && i["spotObjects"] == json!([]) && i["spotColors"] == json!([]), "{i}"); + let i = s.execute("document.info", &json!({"category": "spotColors"})).unwrap(); + assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("spotColors"), Some("Spot Color Objects"))); + assert_eq!(i["sections"][0]["rows"], json!([["Both", "Ink, Gold"], ["", "Ink"], ["", "Gold"], ["Type", "Ink"]])); + // A selection that holds the group and the object inside it lists that object once. + for ids in [json!([group, b]), json!([b, group])] { + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "spotColors"})).unwrap(); + assert_eq!(i["sections"][0]["rows"], json!([["", "Ink"]]), "{ids}"); + assert_eq!((i["objects"]["spots"].as_u64(), i["spotColorNames"].clone()), (Some(1), json!(["Ink"])), "{ids}"); + } + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "spotColors"})).unwrap(); + assert_eq!((i["sections"][0]["rows"].clone(), i["spotColors"].clone()), (json!([["Type", "Ink"]]), json!(["Ink"]))); + let text = s.execute("document.info", &json!({"category": "spotColors", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nSPOT COLOR OBJECTS\nBoth: Ink, Gold\n: Ink\n: Gold\nType: Ink\n"), "{text}"); + assert!(!text.contains("Spare") && !text.contains("Look"), "{text}"); + } + /// Document Info › Objects counts an object with a gradient or a pattern on both its fill and /// its stroke once, and with Selection Only a selection that holds a group and an object inside /// it counts that object once. diff --git a/crates/engine/src/tests/package.rs b/crates/engine/src/tests/package.rs index c1da365740..2e57ed048c 100644 --- a/crates/engine/src/tests/package.rs +++ b/crates/engine/src/tests/package.rs @@ -515,7 +515,7 @@ fn the_document_info_report_has_every_category() { "OBJECTS", "GRAPHIC STYLES", "BRUSHES", - "SPOT COLORS", + "SPOT COLOR OBJECTS", "PATTERN OBJECTS", "GRADIENT OBJECTS", "SYMBOLS", diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97e5b335d3..3efba2679b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3319,6 +3319,9 @@ digits Malayalam Малаялам Split Path Разделяне на път spot петно Spot Color Спот цвят + Spot Color Objects Обекти със спот цветове + Pattern Objects Обекти с мостри + Gradient Objects Обекти с градиенти Spot Colors Спот цветове Spot Colors… Спот цветове… Spread: Разпространение: diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 75353321ff..76cb818576 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3902,6 +3902,9 @@ theme Appearance Izgled Select All Unused Odaberi sve nekorišteno Sort by Kind Sortiraj po vrsti Sort by Name Sortiraj po nazivu + Spot Color Objects Objekti sa spot bojama + Pattern Objects Objekti s uzorcima + Gradient Objects Objekti s gradijentima Spot Colors Spot boje Spot Colors (0) Spot boje (0) Swatch Options Opcije uzorka boje diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 75e0b9ea20..4f3af5b168 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4287,6 +4287,9 @@ digits Malayalam Malaiàlam Sponge… Esponja… spot directe Spot Color Color directe + Spot Color Objects Objectes amb colors directes + Pattern Objects Objectes amb motius + Gradient Objects Objectes amb degradats Spot Colors Colors directes Spot Colors (0) Colors directes (0) Spot Colors… Colors directes… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5d37ee136e..5c154ce842 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3954,6 +3954,9 @@ theme Appearance Motiv Select All Unused Vybrat všechny nepoužité Sort by Kind Seřadit podle druhu Sort by Name Seřadit podle názvu + Spot Color Objects Objekty s přímými barvami + Pattern Objects Objekty se vzorky + Gradient Objects Objekty s přechody Spot Colors Přímé barvy Spot Colors (0) Přímé barvy (0) Swatch Options Možnosti položky vzorníku diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 8167f80138..1ed0154d45 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3677,6 +3677,9 @@ theme Appearance Udseende Select All Unused Markér alle ubrugte Sort by Kind Sortér efter type Sort by Name Sortér efter navn + Spot Color Objects Objekter med spotfarver + Pattern Objects Objekter med mønstre + Gradient Objects Objekter med gradienter Spot Colors Spotfarver Spot Colors (0) Spotfarver (0) Swatch Options Indstillinger for farveprøve diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index bd4bb38b43..7c97be63f8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3378,6 +3378,9 @@ effect Points Punkte Split Path Pfad teilen spot Vollton Spot Color Volltonfarbe + Spot Color Objects Objekte mit Volltonfarben + Pattern Objects Objekte mit Mustern + Gradient Objects Objekte mit Verläufen Spot Colors Volltonfarben Spot Colors… Volltonfarben… Spread: Ausbreitung: diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9860f8ad50..d52569ceec 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3832,6 +3832,9 @@ digits Malayalam Μαλαγιάλαμ Split Into Grid… Διαίρεση σε πλέγμα… Split Path Διαίρεση διαδρομής spot πλακάτο + Spot Color Objects Αντικείμενα με χρώματα spot + Pattern Objects Αντικείμενα με μοτίβα + Gradient Objects Αντικείμενα με ντεγκραντέ Spot Colors Πλακάτα χρώματα Spot Colors… Πλακάτα χρώματα… Stacked Bar Graph Tool Στοιβαγμένο ραβδόγραμμα (Stacked Bar Graph Tool) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 10c0c0bf87..002428f251 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3408,6 +3408,9 @@ digits Malayalam Malayálam Split Path Dividir trazado spot plana Spot Color Tinta plana + Spot Color Objects Objetos con tintas planas + Pattern Objects Objetos con motivos + Gradient Objects Objetos con degradados Spot Colors Tintas planas Spot Colors… Tintas planas… Spread: Extensión: diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7845235f65..9e106dea76 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3878,6 +3878,9 @@ theme Appearance Ulkoasu Select All Unused Valitse kaikki käyttämättömät Sort by Kind Lajittele lajin mukaan Sort by Name Lajittele nimen mukaan + Spot Color Objects Objektit, joissa on spottivärejä + Pattern Objects Objektit, joissa on kuvioita + Gradient Objects Objektit, joissa on liukuvärejä Spot Colors Spottivärit Spot Colors (0) Spottivärit (0) Swatch Options Värimallin asetukset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5e2895f9c2..b0b07185aa 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3400,6 +3400,9 @@ digits Malayalam Malayalam Split Path Scinder le tracé spot ton direct Spot Color Ton direct + Spot Color Objects Objets avec tons directs + Pattern Objects Objets avec motifs + Gradient Objects Objets avec dégradés Spot Colors Tons directs Spot Colors… Tons directs… Spread: Étendue : diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index aca21d7eec..c06fda4262 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4645,6 +4645,9 @@ antiAlias Sharp शार्प Sponge… स्पंज… spot स्पॉट Spot Color स्पॉट रंग + Spot Color Objects स्पॉट कलर्स के साथ ऑब्जेक्ट्स + Pattern Objects पैटर्न्स के साथ ऑब्जेक्ट्स + Gradient Objects ग्रेडिएंट्स के साथ ऑब्जेक्ट्स Spot Colors स्पॉट रंग Spot Colors (0) स्पॉट कलर्स (0) Spot Colors… स्पॉट कलर्स… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 45d19b0ca9..ac85d4a968 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4060,6 +4060,9 @@ mark-style Japanese Japán Split Path Útvonal kettévágása spot direkt Spot Color Direktszín + Spot Color Objects Direkt színeket használó objektumok + Pattern Objects Mintázatokat használó objektumok + Gradient Objects Színátmeneteket használó objektumok Spot Colors Direktszínek Spot Colors (0) Direktszínek (0) Spot Colors… Direktszínek… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8e2b383e2d..ac76a329b0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3677,6 +3677,9 @@ theme Appearance Tampilan Select All Unused Pilih semua yang tidak digunakan Sort by Kind Urutkan berdasarkan jenis Sort by Name Urutkan berdasarkan nama + Spot Color Objects Objek dengan Warna Spot + Pattern Objects Objek dengan Pola + Gradient Objects Objek dengan Gradien Spot Colors Warna spot Spot Colors (0) Warna spot (0) Swatch Options Opsi sampel diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 26e1f29651..7d2284d8c3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3453,6 +3453,9 @@ digits Malayalam Malayalam Split Path Dividi tracciato spot spot Spot Color Colore spot + Spot Color Objects Oggetti con colori spot + Pattern Objects Oggetti con motivi + Gradient Objects Oggetti con sfumature Spot Colors Colori spot Spot Colors… Colori spot… Spread: Estensione: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 30873028e8..a75330e19d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3228,6 +3228,9 @@ mark-style Japanese 日本式 Add Guides ガイドを追加 Split Path パスを分割 Spot Color 特色 + Spot Color Objects 特色を使用したオブジェクト + Pattern Objects パターンを使用したオブジェクト + Gradient Objects グラデーションを使用したオブジェクト Spot Colors 特色 Spot Colors… 特色… Spread: 拡散: diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8e9d9d049d..00d54b057f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3513,6 +3513,9 @@ theme Appearance იერსახე Select All Unused ყველა გამოუყენებლის მონიშვნა Sort by Kind ტიპის მიხედვით დალაგება Sort by Name სახელის მიხედვით დალაგება + Spot Color Objects ობიექტები სპოტ-ფერებით + Pattern Objects ობიექტები პატერნებით + Gradient Objects ობიექტები გრადიენტებით Spot Colors სპოტ-ფერები Swatch Options ფერის ნიმუშის პარამეტრები Ungroup Color Group ფერების ჯგუფის დაშლა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f00e6cdeba..b581143b5f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3217,6 +3217,9 @@ mark-style Japanese 일본식 Add Guides 안내선 추가 Split Path 패스 분할 Spot Color 별색 + Spot Color Objects 별색을 사용한 개체 + Pattern Objects 패턴을 사용한 개체 + Gradient Objects 그레이디언트를 사용한 개체 Spot Colors 별색 Spot Colors… 별색… Spread: 확산: diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index bc00b76698..fd5aed2005 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3691,6 +3691,9 @@ theme Appearance Vormgeving Select All Unused Alle ongebruikte selecteren Sort by Kind Sorteren op soort Sort by Name Sorteren op naam + Spot Color Objects Objecten met spotkleuren + Pattern Objects Objecten met patronen + Gradient Objects Objecten met verlopen Spot Colors Spotkleuren Spot Colors (0) Spotkleuren (0) Swatch Options Kleurstaalopties diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 23940fb80f..c5dcbded1b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3813,6 +3813,9 @@ theme Appearance Wygląd Select All Unused Zaznacz wszystkie nieużywane Sort by Kind Sortuj według rodzaju Sort by Name Sortuj według nazwy + Spot Color Objects Obiekty z kolorami dodatkowymi + Pattern Objects Obiekty ze wzorkami + Gradient Objects Obiekty z gradientami Spot Colors Kolory dodatkowe Swatch Options Opcje próbki Ungroup Color Group Rozgrupuj grupę kolorów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 32c4b4bcc0..9b2a714815 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2860,6 +2860,9 @@ digits Malayalam Malaiala Split Into Grid… Dividir em Grade… Split Path Dividir Caminho Spot Color Cor Spot + Spot Color Objects Objetos com cores spot + Pattern Objects Objetos com padrões + Gradient Objects Objetos com gradientes Spot Colors Cores Spot Spot Colors… Cores Spot… Spread: Propagação: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 15d3c3a380..7f4069d10c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3440,6 +3440,9 @@ digits Malayalam Малаялам Split Path Разделить контур spot плашечный Spot Color Плашечный цвет + Spot Color Objects Объекты с плашечными цветами + Pattern Objects Объекты с узорами + Gradient Objects Объекты с градиентами Spot Colors Плашечные цвета Spot Colors… Плашечные цвета… Spread: Разворот: diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 5ffc963fad..71056ff78c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3330,6 +3330,9 @@ digits Malayalam Malayalam Split Path Dela bana spot dekor Spot Color Dekorfärg + Spot Color Objects Objekt med dekorfärger + Pattern Objects Objekt med mönster + Gradient Objects Objekt med gradienter Spot Colors Dekorfärger Spot Colors… Dekorfärger… Spread: Spridning: diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index ade1efa8be..33e6d5a7e7 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -225,6 +225,28 @@ fn control_bar_panel_menu_labels_are_in_complete_catalogs() { } } +/// The titles of Document Info's Spot Color Objects, Pattern Objects and Gradient Objects +/// categories, which the panel, its menu and the Objects category's counts show. +#[test] +fn document_info_object_category_titles_are_in_complete_catalogs() { + let mut session = vectorcraft_engine::Session::new(); + session.execute("file.new", &serde_json::json!({"width": 100, "height": 100})).unwrap(); + let info = session.execute("document.info", &serde_json::json!({})).unwrap(); + let titles: Vec<&str> = info["sections"] + .as_array() + .unwrap() + .iter() + .filter(|s| matches!(s["id"].as_str(), Some("spotColors" | "patterns" | "gradients"))) + .filter_map(|s| s["title"].as_str()) + .collect(); + assert_eq!(titles, ["Spot Color Objects", "Pattern Objects", "Gradient Objects"]); + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + for title in &titles { + assert!(lang.catalog().plain(title).is_some(), "{} is missing {title}", lang.code); + } + } +} + /// The Character panel's Anti-aliasing Method label and its choices, with the Sharp method's /// own `antiAlias` row apart from the plain "Sharp" corner row. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 86356d74ba..7c5b920e37 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3231,6 +3231,9 @@ digits Malayalam Malayalam Split Path Bölünmüş Yol spot nokta Spot Color Nokta Rengi + Spot Color Objects Spot Renkli Nesneler + Pattern Objects Desenli Nesneler + Gradient Objects Degradeli Nesneler Spot Colors Nokta Renkleri Spot Colors… Nokta Renkleri… Spread: Yayılma: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ebba0f5c19..03873cf8cc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3446,6 +3446,9 @@ effect Points Точки Split Path Розділити контур spot плашковий Spot Color Плашковий колір + Spot Color Objects Об’єкти з плашковими кольорами + Pattern Objects Об’єкти з візерунками + Gradient Objects Об’єкти з градієнтами Spot Colors Плашкові кольори Spot Colors… Плашкові кольори… Spread: Розворот: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 0ec0fd4e40..d95fc6fd3e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3792,6 +3792,9 @@ mark-style Japanese Kiểu Nhật Split Path Tách đường dẫn spot đặc biệt Spot Color Màu đặc biệt + Spot Color Objects Đối tượng có màu đặc biệt + Pattern Objects Đối tượng có họa tiết + Gradient Objects Đối tượng có chuyển sắc Spot Colors Màu đặc biệt Spot Colors… Màu đặc biệt… Spray Radius Bán kính phun diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9fc1cdc4e8..1e1cf55490 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3273,6 +3273,9 @@ digits Malayalam 马拉雅拉姆数字 Split Path 分割路径 spot 专色 Spot Color 专色 + Spot Color Objects 使用专色的对象 + Pattern Objects 使用图案的对象 + Gradient Objects 使用渐变的对象 Spot Colors 专色 Spot Colors… 专色… Spread: 扩散: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 685f7887fb..ba2925719a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4415,6 +4415,9 @@ digits Malayalam 馬來亞拉姆數字 Split Path 分割路徑 spot 特別色 Spot Color 特別色 + Spot Color Objects 使用特別色的物件 + Pattern Objects 使用圖樣的物件 + Gradient Objects 使用漸層的物件 Spot Colors 特別色 Spot Colors… 特別色… Spread: 擴散: diff --git a/crates/ui-egui/src/panels/doc_info.rs b/crates/ui-egui/src/panels/doc_info.rs index 4d6bf05ec1..e450da63aa 100644 --- a/crates/ui-egui/src/panels/doc_info.rs +++ b/crates/ui-egui/src/panels/doc_info.rs @@ -1,5 +1,5 @@ //! Document Info panel: the document's setup and what it contains (objects by kind, graphic -//! styles, brushes, spot colors, objects painted with patterns and with gradients, symbols, fonts and their files, +//! styles, brushes, objects painted with spot colors, with patterns and with gradients, symbols, fonts and their files, //! linked and embedded images, repeat objects), for the whole document or the selection only. The flyout picks one category (or all) //! and saves the text report (`docInfo.save`, the report File → Package writes too). @@ -192,6 +192,7 @@ mod tests { && menu_text.contains("Brushes") && menu_text.contains("Repeat Objects") && menu_text.contains("Gradient Objects") + && menu_text.contains("Spot Color Objects") && menu_text.contains("Save…"), "{menu_text}" ); @@ -205,6 +206,17 @@ mod tests { show(app, ui); }); assert!(gradients.contains("Gradient Objects (1)") && gradients.contains("Glow") && gradients.contains("Radial gradient"), "{gradients}"); + // Spot Color Objects lists the objects painted with a spot color by name. + let spot = json!({"model": "cmyk", "c": 0.0, "m": 0.8, "y": 0.6, "k": 0.0}); + app.session.execute("swatch.new", &json!({"name": "Signal Red", "color": spot, "spot": true})).unwrap(); + let badge = app.session.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.session.execute("paint.setFill", &json!({"ids": [badge], "swatch": "Signal Red"})).unwrap(); + app.session.execute("layer.setProps", &json!({"id": badge, "name": "Badge"})).unwrap(); + let spots = crate::tests_labels::painted_text(&mut app, |app, ui| { + set_pstate(ui.ctx(), "docinfo-category", "spotColors".to_string()); + show(app, ui); + }); + assert!(spots.contains("Spot Color Objects (1)") && spots.contains("Badge") && spots.contains("Signal Red"), "{spots}"); let filtered = crate::tests_labels::painted_text(&mut app, |app, ui| { set_pstate(ui.ctx(), "docinfo-category", "fonts".to_string()); show(app, ui); @@ -222,6 +234,7 @@ mod tests { let report = String::from_utf8(written.borrow().clone()).unwrap(); assert!(report.starts_with("Document Info: ") && report.contains("\nFONT DETAILS\n") && report.contains("\nREPEAT OBJECTS\n"), "{report}"); assert!(report.contains("\nGRADIENT OBJECTS\nGlow: Radial gradient\n"), "{report}"); + assert!(report.contains("\nSPOT COLOR OBJECTS\nBadge: Signal Red\n"), "{report}"); } /// The report the panel menu's Save… writes with category `category` shown ("" for All diff --git a/docs/mcp.md b/docs/mcp.md index 7bf5ae9956..05d6951cd2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6094,7 +6094,12 @@ the patterns it uses, which the patterns category lists as Pattern Objects and ` stroked with a gradient, by the same rules as `patternObjects`, with the gradients it uses: the name of the gradient swatch a gradient is linked to, else `Linear gradient`, `Radial gradient` or `Freeform gradient`; the gradients category lists them as Gradient Objects and `objects.gradients` counts them; gradient swatches no art uses are not listed; with -`selectionOnly`, those in the selection), `gradientNames` (the gradients those objects use) and `pathLength` (pt: the total length of the paths, compound paths' pieces included and guides left +`selectionOnly`, those in the selection), `gradientNames` (the gradients those objects use), `spotObjects` +(`[{id, name, spotColors}]`: every object filled or stroked with a spot color swatch, as a solid color or in a +gradient's stops, at a tint above 0, by the same rules as `patternObjects`, with the spot colors it uses; a process +color, even one of the same color, and Registration are not spot colors; the spotColors category lists them as Spot +Color Objects and `objects.spots` counts them; spot swatches no art uses are not listed; with `selectionOnly`, those +in the selection), `spotColorNames` and `spotColors` (the spot colors those objects use) and `pathLength` (pt: the total length of the paths, compound paths' pieces included and guides left out, which objects lists as Path Length in the document's units, say for a cut line or an LED strip along the letters); `format: "text"` returns the plain-text report Document Info › Save… (`docInfo.save {path?, selectionOnly?, category?}`: only `category` when given, else every category) and the package report write. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b1ceaf70ea..900787b283 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1352,6 +1352,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Document Info has a Repeat Objects category after Embedded Images. It lists each radial, grid and mirror Repeat object by name with its kind and the number of instances it makes. For a grid it gives the rows and columns instead, followed by the number of instances it draws when that is fewer. With Selection Only it lists the Repeat objects in the selection. The Document Info report and Package's report list them too (`document.info {category: "repeats"}`, `repeatObjects`). Document Info's Pattern Objects category lists the objects whose fill or stroke is a pattern, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, each by name with the patterns it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "patterns"}`, `patternObjects`). Listing an object with a pattern fill and a pattern stroke once, listing objects inside groups and type objects whose characters use a pattern, and leaving out hidden fills and strokes, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes without a shape, characters the type does not draw and patterns no art uses, and counting the listed objects in Objects, are VectorCraft's own rules. Test: `cmd::docinfo::tests::info_lists_the_pattern_objects` (vectorcraft-engine). Document Info's Gradient Objects category lists the objects whose fill or stroke is a gradient, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as the count of objects with gradient fills or strokes. Each object is listed by name with the gradients it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "gradients"}`, `gradientObjects`). Naming a linked gradient by its swatch and any other gradient by its kind, and leaving out paint that draws nothing by the rules Pattern Objects follows, gradient swatches no art uses, gradient meshes and gradients drawn by effects, are VectorCraft's own rules. Objects counts the listed objects as Gradient Objects. Test: `cmd::docinfo::tests::info_lists_the_gradient_objects` (vectorcraft-engine). + Document Info's Spot Color Objects category lists the objects that use spot colors, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as objects using spot color separations. Each object is listed by name with the spot colors it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "spotColors"}`, `spotObjects`). Taking a solid color or a gradient stop linked to a spot swatch at a tint above 0, leaving out process colors that look the same, Registration, paint that draws nothing by the rules Pattern Objects follows, spot swatches no art uses and spot colors inside patterns, symbols, opacity masks, effects and placed files, are VectorCraft's own rules. Objects counts the listed objects as Spot Color Objects. Test: `cmd::docinfo::tests::info_lists_the_spot_color_objects` (vectorcraft-engine). Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. From 3c1e3cc1120f48dc0dcb8ea158f9067caf110ede Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:00:55 -0800 Subject: [PATCH 20/79] 3D: say that a clipped composite keeps the whole solid in member order With colored lights a surface can be one clipped composite of its facets. The comment above the plain check now states that every surface of such a solid, masked ones included, draws whole in member order. --- crates/effects/src/solids.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 4744202dd0..65bff78e7e 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -842,8 +842,9 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option Date: Thu, 12 Nov 2026 01:03:41 -0800 Subject: [PATCH 21/79] Read roots: links.findFiles and image.info follow the configured read roots With automation read roots in force, links.findFiles and text.findFontFiles replies, including later polls and stops of a search another client started, leave out the files found outside the caller's read roots, a sought link's saved path outside them and the folder searched when it is outside them. A failed search then gives its reason without the path. The rows keep their names, ids and status, and the counts stay. image.info gives link: null for a linked file outside the read roots, as links.info does. text.addFontFiles gives folder: null when the Fonts folder is outside the read roots and names the copies there by their file names; the fonts are still copied. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/findfiles/tests.rs | 12 ++- crates/engine/src/cmd/findfiles/walk.rs | 66 ++++++++++--- crates/engine/src/cmd/fontfiles.rs | 28 ++++-- crates/engine/src/cmd/linkfiles.rs | 23 +++-- crates/engine/src/cmd/links.rs | 4 +- crates/engine/src/cmd/place.rs | 6 +- crates/engine/src/tests/fontfiles.rs | 37 ++++++++ crates/engine/src/tests/linkfiles.rs | 112 ++++++++++++++++++++++- crates/engine/tests/font_folder.rs | 7 ++ docs/mcp.md | 8 ++ docs/target-app-parity.md | 1 + 11 files changed, 266 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/findfiles/tests.rs b/crates/engine/src/cmd/findfiles/tests.rs index ec3ce57e3a..bf51ebdac7 100644 --- a/crates/engine/src/cmd/findfiles/tests.rs +++ b/crates/engine/src/cmd/findfiles/tests.rs @@ -183,6 +183,16 @@ fn a_picked_folder_inside_other_apps_folders_is_refused() { assert!(p.error().is_some_and(|e| e.ends_with("is not a folder")), "{p:?}"); let p = search(&root, "missing", Limits::default(), 1, &[]); assert!(p.error().is_some_and(|e| e.starts_with(&root.join("missing").display().to_string())), "{p:?}"); + // Each failure has the same reason without the folder's path. + let briefs = [ + ("missing", "the folder can't be read"), + ("file.ttf", "the path isn't a folder"), + ("refused", "VectorCraft doesn't search other apps' or the system's folders"), + ("Users/Public/x", "VectorCraft doesn't search the Shared or Public folder in Users"), + ]; + for (picked, why) in briefs { + assert_eq!(search(&root, picked, Limits::default(), 1, &[]).failure(), Some(why), "{picked}"); + } } #[test] @@ -362,7 +372,7 @@ fn a_picked_folder_that_cant_be_listed_fails_the_search() { let p = search(&root, "locked", Limits::default(), 2, &["y.ttf"]); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); if enforced { - assert!(matches!(&p.end, Some(End::Failed(e)) if e.contains("locked")), "{p:?}"); + assert!(matches!(&p.end, Some(End::Failed(e, "the folder can't be listed")) if e.contains("locked")), "{p:?}"); } else { assert_eq!(found(&root, &p), ["locked/y.ttf"]); } diff --git a/crates/engine/src/cmd/findfiles/walk.rs b/crates/engine/src/cmd/findfiles/walk.rs index 8d35515717..ae05a04838 100644 --- a/crates/engine/src/cmd/findfiles/walk.rs +++ b/crates/engine/src/cmd/findfiles/walk.rs @@ -23,6 +23,13 @@ const CHECK_EVERY: u64 = 1024; /// How a search ends when every walker thread stopped on a bug. const INTERNAL: &str = "every search thread stopped on an internal error (please report this bug)"; +/// Why a search couldn't start, without the folder's path ([`Progress::failure`]). +const UNREADABLE: &str = "the folder can't be read"; +const NOT_A_FOLDER: &str = "the path isn't a folder"; +const SHARED: &str = "VectorCraft doesn't search the Shared or Public folder in Users"; +const APPS: &str = "VectorCraft doesn't search other apps' or the system's folders"; +const UNLISTED: &str = "the folder can't be listed"; + /// What a search looks for. pub trait Visitor: Send + Sync { /// Whether the regular file named `name` is to be read: asked of the files a folder lists until @@ -77,8 +84,9 @@ pub enum End { /// reached their [`Limits`]. Folders deeper than [`Limits::depth`] are skipped and an item /// keeps at most [`Limits::per_item`] files, without ending the search. Limit, - /// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. - Failed(String), + /// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. Why, + /// and the same without the folder's path. + Failed(String, &'static str), } /// Where a search stands ([`Search::progress`]). @@ -114,7 +122,7 @@ impl Progress { None => "searching", Some(End::Done | End::Found) => "done", Some(End::Stopped | End::Time | End::Limit) => "stopped", - Some(End::Failed(_)) => "failed", + Some(End::Failed(..)) => "failed", } } @@ -131,10 +139,38 @@ impl Progress { /// Why a failed search failed. pub fn error(&self) -> Option<&str> { match &self.end { - Some(End::Failed(e)) => Some(e), + Some(End::Failed(e, _)) => Some(e), _ => None, } } + + /// Why a failed search failed, without the folder's path. + pub fn failure(&self) -> Option<&'static str> { + match &self.end { + Some(End::Failed(_, why)) => Some(why), + _ => None, + } + } + + /// Keep what the caller asking now may read under its read roots ([`crate::file_access::current`]), + /// for a reply to it: the files found inside them. Returns the folder searched (`given` until a + /// walker checked it) and why the search failed, when that folder lies inside them too; + /// otherwise no folder and why without its path ([`Self::failure`]). Without read roots in + /// force, everything is kept. + pub fn keep_readable(&mut self, given: &str) -> (Option, Option) { + let folder = if self.folder.as_os_str().is_empty() { given.to_string() } else { self.folder.to_string_lossy().into_owned() }; + if crate::file_access::current().is_none() { + return (Some(folder), self.error().map(str::to_string)); + } + // A path that isn't valid text is never shown: its lossy form names another file. + let readable = |p: &Path| p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok()); + self.hits.retain(|(path, _)| readable(path)); + if readable(Path::new(given)) && (self.folder.as_os_str().is_empty() || readable(&self.folder)) { + (Some(folder), self.error().map(str::to_string)) + } else { + (None, self.failure().map(str::to_string)) + } + } } /// Walker threads shared out among searches: all of them together run at most `cap` threads. @@ -306,7 +342,7 @@ pub(crate) fn start_in( return Err("the search couldn't start: try again in a moment".into()); } if w.alive == 0 && w.end.is_none() { - shared.finish(&mut w, End::Failed(INTERNAL.into())); + shared.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)); } } Ok(Search { shared }) @@ -401,13 +437,13 @@ impl Shared { fn prepare(&self, root: &Path) { let rules = self.rules.get_or_init(|| self.given.clone().unwrap_or_else(Rules::system)); let path = root.display(); - let checked = match std::fs::canonicalize(root) { - Err(e) => Err(format!("{path}: {e}")), - Ok(folder) if !folder.is_dir() => Err(format!("{path} is not a folder")), + let (checked, brief) = match std::fs::canonicalize(root) { + Err(e) => (Err(format!("{path}: {e}")), UNREADABLE), + Ok(folder) if !folder.is_dir() => (Err(format!("{path} is not a folder")), NOT_A_FOLDER), Ok(folder) => match rules.refusal(&folder) { - None => Ok(folder), - Some(Refusal::Shared) => Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), - Some(Refusal::Apps) => Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), + None => (Ok(folder), INTERNAL), + Some(Refusal::Shared) => (Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), SHARED), + Some(Refusal::Apps) => (Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), APPS), }, }; let mut w = self.lock(); @@ -416,7 +452,7 @@ impl Shared { w.folder = folder.clone(); w.stack.push((folder, 0)); } - Err(e) => self.finish(&mut w, End::Failed(e)), + Err(e) => self.finish(&mut w, End::Failed(e, brief)), } } @@ -430,7 +466,7 @@ impl Shared { let mut w = self.lock(); // The picked folder itself: the search can't run, and says why. if depth == 0 { - self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()))); + self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()), UNLISTED)); } else { w.unreadable += 1; w.folders += 1; @@ -610,7 +646,7 @@ fn walker(s: &Shared) { if done.is_err() { let mut w = s.lock(); match job { - Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into())), + Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)), Job::List(..) => w.unreadable += 1, } } @@ -640,7 +676,7 @@ impl Drop for Alive { let mut w = self.0.lock(); w.alive = w.alive.saturating_sub(1); if w.alive == 0 && w.end.is_none() { - self.0.finish(&mut w, End::Failed(INTERNAL.into())); + self.0.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)); } drop(w); self.0.pool.release(1); diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index 309347985c..0ccf1ab94a 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -76,7 +76,7 @@ pub fn specs() -> Vec { "Find Font Files", [], None, - "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", + "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links). With automation read roots, a reply leaves out the files found outside the caller's roots, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", always, find ), @@ -85,7 +85,7 @@ pub fn specs() -> Vec { "Add Font Files", [], None, - "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}", + "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install. With automation read roots, folder is null when the Fonts folder is outside them, and a to or file path outside them is given as its file name; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}", always, add ), @@ -275,7 +275,9 @@ fn find(s: &mut Session, p: &Value) -> Result { /// Where the session's last search stands (`text.findFontFiles` without `folder`). fn state(s: &Session) -> Value { let Some(f) = &s.font_search else { return json!({ "state": "idle", "fonts": [] }) }; - let p = f.search.progress(); + let mut p = f.search.progress(); + // Only what the caller asking now may read: a search another caller started can hold more. + let (folder, error) = p.keep_readable(&f.folder); let mut files: Vec> = vec![vec![]; f.fonts.len()]; for (path, items) in &p.hits { for i in items { @@ -293,16 +295,18 @@ fn state(s: &Session) -> Value { json!({ "family": font.family, "style": font.style, "status": font.status.as_str(), "files": files }) }) .collect(); - let folder = if p.folder.as_os_str().is_empty() { f.folder.clone() } else { p.folder.to_string_lossy().into_owned() }; let mut out = json!({ - "id": f.id, "state": p.state(), "folder": folder, "fonts": fonts, + "id": f.id, "state": p.state(), "fonts": fonts, "searched": { "folders": p.folders, "files": p.files, "fontFiles": p.read }, "skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0, }); + if let Some(folder) = folder { + out["folder"] = json!(folder); + } if let Some(why) = p.stopped() { out["stopped"] = json!(why); } - if let Some(e) = p.error() { + if let Some(e) = error { out["error"] = json!(e); } out @@ -348,12 +352,16 @@ fn add(s: &mut Session, p: &Value) -> Result { // VectorCraft's own Fonts folder: no automation root applies to it. crate::file_access::unconfined(|| super::fileio::create_dir(&dir.to_string_lossy()))?; let report = copy_into(&files, dir); - let pairs = |v: &[(PathBuf, PathBuf)]| { - v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::>() + // The caller sees the Fonts folder and the copies in it only where its read roots reach. + let readable = |p: &Path| crate::file_access::current().is_none() || p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok()); + let shown = |p: &Path| -> String { + let shown = if readable(p) { Some(p.as_os_str()) } else { p.file_name() }; + shown.map(|s| s.to_string_lossy().into_owned()).unwrap_or_default() }; + let pairs = |v: &[(PathBuf, PathBuf)]| v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": shown(to) })).collect::>(); let mut out = json!({ - "folder": dir.to_string_lossy(), "copied": pairs(&report.copied), "kept": pairs(&report.kept), - "skipped": report.skipped.iter().map(|(file, why)| json!({ "file": file.to_string_lossy(), "reason": why })).collect::>(), + "folder": readable(dir).then(|| dir.to_string_lossy()), "copied": pairs(&report.copied), "kept": pairs(&report.kept), + "skipped": report.skipped.iter().map(|(file, why)| json!({ "file": shown(file), "reason": why })).collect::>(), }); if !report.copied.is_empty() || !report.kept.is_empty() { // The fonts are cataloged and type set in them redraws, as Refresh Font List does it. diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs index 5b5a6aa48a..f2ce9d4d12 100644 --- a/crates/engine/src/cmd/linkfiles.rs +++ b/crates/engine/src/cmd/linkfiles.rs @@ -29,7 +29,7 @@ pub fn specs() -> Vec { "Find Linked Files", [], None, - "{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits; none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", + "{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits. With automation read roots, a reply leaves out the files found outside the caller's roots, a link's path outside them, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path?, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", always, find )] @@ -52,6 +52,8 @@ pub(crate) struct LinkSearch { pub(crate) id: u64, /// The folder as given. pub(crate) folder: String, + /// The path of the document the links were taken from, which a relative link path starts from. + pub(crate) doc: Option, pub(crate) links: Vec, /// The size of each file read, by the path the search reports ([`LinkFiles::sizes`]). pub(crate) sizes: Arc>>, @@ -163,6 +165,7 @@ fn find(s: &mut Session, p: &Value) -> Result { if links.is_empty() { return Err(bad(C, if ids.is_some() { "these objects show no linked files" } else { "no linked files are missing" })); } + let doc = super::links::LinkBase::of(st).doc.map(str::to_string); // A new search stops the last one. s.link_search = None; let threads = s.search_threads.unwrap_or_else(findfiles::threads); @@ -171,7 +174,7 @@ fn find(s: &mut Session, p: &Value) -> Result { let visitor = Arc::new(LinkFiles::new(&links, sizes.clone())); let search = findfiles::start(PathBuf::from(&folder), s.search_rules.clone(), limits, threads, 2 * links.len(), visitor).map_err(EngineError::Other)?; - s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, links, sizes, search }); + s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, doc, links, sizes, search }); Ok(state(s)) } @@ -209,7 +212,9 @@ fn shared_folders(saved: &str, found: &Path) -> usize { /// Where the session's last search stands (`links.findFiles` without `folder`). fn state(s: &Session) -> Value { let Some(l) = &s.link_search else { return json!({ "state": "idle", "links": [] }) }; - let p = l.search.progress(); + let mut p = l.search.progress(); + // Only what the caller asking now may read: a search another caller started can hold more. + let (folder, error) = p.keep_readable(&l.folder); let n = l.links.len(); let sizes = lock(&l.sizes).clone(); // Per linked file: the files found, each with whether it is the same file. @@ -267,19 +272,23 @@ fn state(s: &Session) -> Value { f }) .collect(); - json!({ "name": link.name, "path": link.path, "ids": link.ids, "files": files }) + let mut row = json!({ "name": link.name, "ids": link.ids, "files": files }); + super::links::note_path(&mut row, "path", &link.path, l.doc.as_deref()); + row }) .collect(); - let folder = if p.folder.as_os_str().is_empty() { l.folder.clone() } else { p.folder.to_string_lossy().into_owned() }; let mut out = json!({ - "id": l.id, "state": p.state(), "folder": folder, "links": links, + "id": l.id, "state": p.state(), "links": links, "searched": { "folders": p.folders, "files": p.files, "read": p.read }, "skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0, }); + if let Some(folder) = folder { + out["folder"] = json!(folder); + } if let Some(why) = p.stopped() { out["stopped"] = json!(why); } - if let Some(e) = p.error() { + if let Some(e) = error { out["error"] = json!(e); } out diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a134eb1c55..89c0afefcf 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -898,12 +898,12 @@ fn rows<'a>(gs: impl Iterator, doc_path: Option<&str>) -> Vec< /// May a reply to the caller asking now carry the link path `path` (of the document saved at /// `doc_path`)? Always without read roots in force; with them, only when the file it names (a /// relative path taken from the document's folder) lies inside them. -fn path_shown(path: &str, doc_path: Option<&str>) -> bool { +pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool { crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok()) } /// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`. -fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) { +pub(crate) fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) { if path_shown(path, doc_path) { row[key] = json!(path); } else if let Some(o) = row.as_object_mut() { diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index a6e7f67286..5d56a2b646 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "Image Info", [], None, - "{id?} an image object (default: the one selected) → {id, name, linked, link, colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}", + "{id?} an image object (default: the one selected) → {id, name, linked, link (the linked file's path; null when embedded, or when automation read roots are in force and the file is outside them), colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}", has_doc, image_info ), @@ -891,11 +891,13 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result { let [a, b, c, d, ..] = im.xf.as_coeffs(); let ppi = |x: f64, y: f64| Some(x.hypot(y)).filter(|l| *l > 1e-12).map_or(0.0, |l| 72.0 / l); let size = n.geometric_bounds().unwrap_or_default(); + // A linked file outside the read roots of the caller asking now is left unnamed, as in Link Info. + let doc_path = super::links::LinkBase::of(st).doc; Ok(json!({ "id": id.0, "name": n.display_name(), "linked": im.link.is_some(), - "link": im.link.as_ref().map(|l| &l.path), + "link": im.link.as_ref().map(|l| &l.path).filter(|path| super::links::path_shown(path, doc_path)), "colorMode": st.doc.images.get(&im.key).map_or("RGB", |b| color_mode(&b.bytes)), "pixelWidth": im.width, "pixelHeight": im.height, diff --git a/crates/engine/src/tests/fontfiles.rs b/crates/engine/src/tests/fontfiles.rs index f31df35eb1..8bef8733b5 100644 --- a/crates/engine/src/tests/fontfiles.rs +++ b/crates/engine/src/tests/fontfiles.rs @@ -194,6 +194,43 @@ fn a_search_finds_a_missing_fonts_file_by_its_names() { assert!(matches!(r["state"].as_str(), Some("stopped" | "done")), "{r}"); } +/// A caller confined by read roots sees, in the replies to a search another caller started, only +/// the files found inside its read roots, and the folder searched and a failed search's path only +/// when they are inside them. +#[test] +fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() { + let root = folder("read-roots"); + let font = vectorcraft_testkit::fonts::renamed("Findme Sans 3"); + let (home, wide) = (root.join("home"), root.join("lrf-wide")); + for dir in [&home, &wide] { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("Findme.otf"), &font).unwrap(); + } + let mut s = session_for(&root); + let roots = |read: &[&Path]| crate::file_access::AutomationRoots::new(read, &[]).unwrap().unwrap(); + let (narrow, broad) = (roots(&[&home]), roots(&[&root])); + let ask = |s: &mut Session, r: &Arc, p: Value| { + crate::file_access::confine(Some(r), || s.execute("text.findFontFiles", &p).unwrap()) + }; + // A font no file has keeps the search going through every folder. + let fonts = json!([{"family": "Findme Sans 3"}, {"family": "No Such Sans"}]); + ask(&mut s, &broad, json!({"folder": root, "fonts": fonts})); + finished(&mut s); + let all = ask(&mut s, &broad, json!({})); + assert_eq!((&all["fonts"][0]["files"], &all["folder"]), (&json!([home.join("Findme.otf"), wide.join("Findme.otf")]), &json!(root)), "{all}"); + for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] { + assert!(!r.to_string().contains("lrf-wide") && r.get("folder").is_none(), "{r}"); + assert_eq!((&r["fonts"][0]["files"], &r["fonts"][0]["family"]), (&json!([home.join("Findme.otf")]), &json!("Findme Sans 3")), "{r}"); + assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}"); + } + // A search that failed: the reason shows without the path to a caller outside its folder. + ask(&mut s, &broad, json!({"folder": wide.join("gone"), "fonts": fonts})); + assert!(finished(&mut s)["error"].as_str().is_some_and(|e| e.contains("lrf-wide"))); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((r["state"].as_str(), r["error"].as_str(), r.get("folder")), (Some("failed"), Some("the folder can't be read"), None), "{r}"); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn junk_search_params_never_fail_internally() { let root = folder("junk"); diff --git a/crates/engine/src/tests/linkfiles.rs b/crates/engine/src/tests/linkfiles.rs index 695ea41419..ef9127042a 100644 --- a/crates/engine/src/tests/linkfiles.rs +++ b/crates/engine/src/tests/linkfiles.rs @@ -271,7 +271,7 @@ fn stop_ends_only_the_search_it_names() { let (tx, rx) = mpsc::channel(); let search = findfiles::start(root.clone(), s.search_rules.clone(), Limits::default(), 1, 2, Arc::new(Blocked(Mutex::new(rx)))).unwrap(); let links = vec![Sought { name: "a.png".into(), path: "/gone/a.png".into(), ids: vec![], size: None, hash: None }]; - s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), links, sizes: Arc::default(), search }); + s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), doc: None, links, sizes: Arc::default(), search }); let until = Instant::now() + WAIT; while s.execute("links.findFiles", &json!({})).unwrap()["searched"]["read"] != 1 && Instant::now() < until { std::thread::sleep(Duration::from_millis(2)); @@ -284,6 +284,116 @@ fn stop_ends_only_the_search_it_names() { drop(tx); } +/// A caller confined by read roots sees, in the replies to a search another caller started and to +/// its own, only the paths inside its read roots: the files found outside them, a link's saved path +/// outside them and the folder searched are left out, and the rows, ids and counts stay. Without +/// roots, the replies are as before. +#[test] +fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() { + let root = folder("read-roots"); + let mut s = session_for(&root); + let photo = png(40, 30, RED); + write(&root.join("lrf-away/photo.png"), &photo); + let id = placed(&mut s, &root.join("lrf-away/photo.png")); + std::fs::remove_file(root.join("lrf-away/photo.png")).unwrap(); + // Files of the name with other contents: the search reads every folder. + let other = png(40, 30, BLUE); + let (home, wide) = (root.join("home"), root.join("lrf-wide")); + write(&home.join("photo.png"), &other); + write(&wide.join("photo.png"), &other); + let roots = |read: &[&Path], write: &[&Path]| crate::file_access::AutomationRoots::new(read, write).unwrap().unwrap(); + let (narrow, write_only, broad) = (roots(&[&home], &[]), roots(&[], &[&root]), roots(&[&root], &[])); + let ask = |s: &mut Session, r: &Arc, p: Value| { + crate::file_access::confine(Some(r), || s.execute("links.findFiles", &p).unwrap()) + }; + let hidden = |r: &Value| { + let text = r.to_string(); + assert!(!text.contains("lrf-away") && !text.contains("lrf-wide") && r.get("folder").is_none(), "{r}"); + let row = &r["links"][0]; + assert_eq!((row["name"].as_str(), &row["ids"], row.get("path")), (Some("photo.png"), &json!([id]), None), "{r}"); + }; + // Started with the broad roots, over the whole folder. + let r = ask(&mut s, &broad, json!({"folder": root})); + assert_eq!(r["folder"], json!(root), "{r}"); + finished(&mut s); + let all = ask(&mut s, &broad, json!({})); + assert_eq!(all["links"][0]["files"].as_array().map(Vec::len), Some(2), "{all}"); + for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] { + hidden(&r); + assert_eq!(r["links"][0]["files"], json!([{"path": home.join("photo.png"), "size": other.len(), "match": "name"}]), "{r}"); + assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}"); + } + let r = ask(&mut s, &write_only, json!({})); + hidden(&r); + assert_eq!(r["links"][0]["files"], json!([]), "{r}"); + // Without roots, every path shows. + let r = s.execute("links.findFiles", &json!({})).unwrap(); + assert_eq!((&r["folder"], &r["links"][0]["path"]), (&json!(root), &json!(root.join("lrf-away/photo.png"))), "{r}"); + assert_eq!(r["links"][0]["files"].as_array().map(Vec::len), Some(2), "{r}"); + // The narrow caller's own search shows the folder it gave. + let r = ask(&mut s, &narrow, json!({"folder": home})); + assert_eq!(r["folder"], json!(home), "{r}"); + finished(&mut s); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((&r["folder"], r["links"][0].get("path")), (&json!(home), None), "{r}"); + // A search that failed: the reason shows without the path to a caller outside its folder. + ask(&mut s, &broad, json!({"folder": root.join("lrf-wide/gone")})); + let r = finished(&mut s); + assert!(r["error"].as_str().is_some_and(|e| e.contains("lrf-wide")), "{r}"); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((r["state"].as_str(), r["error"].as_str()), (Some("failed"), Some("the folder can't be read")), "{r}"); + hidden(&r); + let _ = std::fs::remove_dir_all(&root); +} + +/// A link's saved path relative to the document is shown to a confined caller when it leads to a +/// file inside its read roots, and left out when `..` leads out of them. +#[test] +fn a_relative_link_path_is_judged_from_the_documents_folder() { + let root = folder("read-roots-relative"); + let mut s = session_for(&root); + let home = root.join("home"); + write(&root.join("lrf-away/photo.png"), &png(40, 30, RED)); + let id = placed(&mut s, &root.join("lrf-away/photo.png")); + std::fs::create_dir_all(&home).unwrap(); + s.execute("document.save", &json!({"path": home.join("poster.vectorcraft")})).unwrap(); + let narrow = crate::file_access::AutomationRoots::new(&[&home], &[]).unwrap().unwrap(); + for (saved, shown) in [("sub/photo.png", true), ("../lrf-away/photo.png", false)] { + s.edit("relative path", |d, _| { + if let NodeKind::Image(im) = &mut d.node_mut(NodeId(id)).unwrap().kind { + im.link.as_mut().unwrap().path = saved.into(); + } + Ok(()) + }) + .unwrap(); + let r = crate::file_access::confine(Some(&narrow), || s.execute("links.findFiles", &json!({"folder": home, "ids": [id]})).unwrap()); + assert_eq!(r["links"][0].get("path"), shown.then(|| json!(saved)).as_ref(), "{saved}: {r}"); + finished(&mut s); + } + let _ = std::fs::remove_dir_all(&root); +} + +/// `image.info` leaves a linked file outside the confined caller's read roots unnamed (`link` +/// null), as `links.info` leaves it out, and keeps the other fields. +#[test] +fn image_info_leaves_out_a_link_outside_the_read_roots() { + let root = folder("read-roots-info"); + let mut s = session_for(&root); + let pic = root.join("lrf-away/photo.png"); + write(&pic, &png(40, 30, RED)); + let id = placed(&mut s, &pic); + std::fs::create_dir_all(root.join("home")).unwrap(); + let narrow = crate::file_access::AutomationRoots::new(&[&root.join("home")], &[]).unwrap().unwrap(); + let ask = |s: &mut Session, cmd: &str| crate::file_access::confine(Some(&narrow), || s.execute(cmd, &json!({"id": id})).unwrap()); + let r = ask(&mut s, "image.info"); + assert_eq!((&r["link"], &r["linked"], &r["pixelWidth"], &r["id"]), (&Value::Null, &json!(true), &json!(40), &json!(id)), "{r}"); + assert!(!r.to_string().contains("lrf-away"), "{r}"); + let info = ask(&mut s, "links.info"); + assert!(info.get("link").is_none() && !info.to_string().contains("lrf-away"), "{info}"); + assert_eq!(s.execute("image.info", &json!({"id": id})).unwrap()["link"], json!(pic)); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn relinking_with_copy_puts_the_file_in_the_documents_folder() { let root = folder("copy"); diff --git a/crates/engine/tests/font_folder.rs b/crates/engine/tests/font_folder.rs index b58e79fc17..11d152fb4d 100644 --- a/crates/engine/tests/font_folder.rs +++ b/crates/engine/tests/font_folder.rs @@ -71,6 +71,13 @@ fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() { assert!(st.revision > revision && !st.is_dirty()); assert_eq!(s.execute("text.missingFonts", &json!({})).unwrap()["count"], 0); + // A caller whose read roots hold the file but not the Fonts folder: the file is kept, and the + // reply names the copy by its file name only, without the folder. + let downloads = vectorcraft_engine::file_access::AutomationRoots::new(&[&root.join("Downloads")], &[]).unwrap(); + let r = vectorcraft_engine::file_access::confine(downloads.as_ref(), || s.execute("text.addFontFiles", &json!({"files": [file]}))).unwrap(); + assert_eq!((&r["folder"], &r["kept"]), (&Value::Null, &json!([{"from": file, "to": "Findme 2.otf"}])), "{r}"); + assert!(!r.to_string().contains("App Fonts"), "{r}"); + // The same file again is kept, not copied a third time. let r = s.execute("text.addFontFiles", &json!({"files": [file]})).unwrap(); assert_eq!((r["copied"].clone(), r["kept"].clone()), (json!([]), json!([{"from": file, "to": fonts.join("Findme 2.otf")}])), "{r}"); diff --git a/docs/mcp.md b/docs/mcp.md index 62b8052a2a..0a2b114487 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -201,6 +201,14 @@ the suite) keep the agent to the folders you give it: and `modified` the document keeps of that file. Each row keeps its id, `name` (`fileName`), status and other fields. This applies to every document, also one the person or a client with wider roots opened. The paths a client gives `links.relink` and `links.unembed` stay in their replies, and the document's links are not changed. +- `image.info` gives `link: null` for a linked file outside the read roots, as `links.info` leaves it out; its id, + name, `linked` and the other fields stay. `links.findFiles` and `text.findFontFiles` replies, including later + `{}` and `{stop: true}` calls on a search another client started, leave out the files found outside the read + roots (with their `size` and `match`), a sought link's `path` outside them, and `folder` when the folder searched + is outside them; `error` then gives the reason without the path (for example `the folder can't be read`). The + rows keep their `name`, `ids`, family, style and status, and the counts stay. `text.addFontFiles` gives + `folder: null` when VectorCraft's Fonts folder is outside the read roots, and only the file name in `copied` and + `kept` `to` and in `skipped` `file` for a path outside them; the fonts are still copied. - `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show a folder preference outside the read roots (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`) as `null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2a1eb83381..405a4fee2d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -763,6 +763,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). + - Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`. - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/` and keeps that id for the session, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. From cdcac345a9bbeb25a3678b3eb3dcaa39b1b4060b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:45:41 -0800 Subject: [PATCH 22/79] Legacy .ai: type keeps Standard Vertical Roman Alignment and Tate-chu-yoko Horizontal type keeps both marks, and the legacy .ai writer writes such type as text, but the marks were dropped without a warning. Version 7 text has operators for both (section 15.3.4 of the published format specification): Tv for Roman characters upright in vertical writing, and TY for a Kumi block in horizontal text recorded for later vertical use. The writer now writes 1 Tv and 0 Tv, and 1 TY and 0 TY, where a run's mark changes, closes an open Kumi block before the text object ends, and writes neither operator for type without the marks. The legacy .ai reader reads both back onto the same characters. The prolog defines Tv and TY so the file still runs as PostScript. --- crates/eps/src/legacy_ai.rs | 5 ++- crates/eps/src/legacy_ai/prolog.rs | 2 +- crates/eps/src/legacy_ai/tests.rs | 53 ++++++++++++++++++++++++++++++ crates/eps/src/legacy_ai/writer.rs | 21 +++++++++++- docs/file-format-parity.md | 3 +- 5 files changed, 80 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index e256e305c2..10af4f8f9a 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -33,7 +33,10 @@ //! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx //! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each //! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`, -//! `Ts Tz Tt TA Tl Ta Ti` and its characters `(…) Tx` (`\r` ends a paragraph), `TO`. Only the +//! `Ts Tz Tt TA Tl Ta Ti` and its characters `(…) Tx` (`\r` ends a paragraph), `TO`. Standard +//! Vertical Roman Alignment is written as `1 Tv` and `0 Tv`, and Tate-chu-yoko in horizontal +//! type as a Kumi block recorded for later use, `1 TY` … `0 TY` (§15.3.4); neither operator is +//! written for type that has neither mark. Only the //! revisable form is written: readers lay the type out again (§15.1). Fonts are named by their //! PostScript names in `%%DocumentFonts` and `%%IncludeFont` (§3.1) and re-encoded to //! Windows-1252 (`TE`, then `%AI3_BeginEncoding: _name name`, `TZ`, §3.3), the 8-bit encoding diff --git a/crates/eps/src/legacy_ai/prolog.rs b/crates/eps/src/legacy_ai/prolog.rs index b0d606bba9..5ee77f89cf 100644 --- a/crates/eps/src/legacy_ai/prolog.rs +++ b/crates/eps/src/legacy_ai/prolog.rs @@ -106,7 +106,7 @@ VCLegacy begin /TE {cleartomark} bd /TZ {cleartomark} bd /To {pop} bd /Tp {7 {pop} repeat} bd /TP {} bd /TO {} bd /Tr {pop} bd /Tf {pop pop pop pop} bd /Ts {pop} bd /Tz {pop pop} bd /Tt {pop} bd /TA {pop} bd -/Tl {pop pop} bd /Ta {pop} bd /Ti {pop pop pop} bd /Tx {pop} bd +/Tl {pop pop} bd /Ta {pop} bd /Ti {pop pop pop} bd /Tx {pop} bd /Tv {pop} bd /TY {pop} bd % The page trailer (3): annotatepage marks nothing. /annotatepage {} bd end diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 43b28c934a..d0dd1f64e8 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1193,6 +1193,59 @@ fn variable_font_settings_are_warned_as_left_out() { assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text"); } +/// Standard Vertical Roman Alignment is written as `Tv` and Tate-chu-yoko in horizontal type as a +/// Kumi block recorded for later use, `TY` (§15.3.4), each where it changes: the marks read back +/// on the same characters, a block is closed before its text object ends, and type with neither +/// mark has neither operator. +#[test] +fn vertical_roman_and_tate_chu_yoko_are_written_and_read_back() { + use vectorcraft_doc::{TextObject, TextRun}; + let plain = style(12.0, Paint::solid(Color::BLACK)); + let roman = vectorcraft_doc::CharStyle { standard_vertical_roman: true, ..plain.clone() }; + let tcy = vectorcraft_doc::CharStyle { tate_chu_yoko: true, ..plain.clone() }; + let both = vectorcraft_doc::CharStyle { tate_chu_yoko: true, standard_vertical_roman: true, ..plain.clone() }; + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let mut marked = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "A", plain.clone()); + for (text, st) in [("12", &tcy), ("b", &roman), ("c", &both), ("34", &tcy)] { + marked.runs.push(TextRun::new(text, st.clone())); + } + let next = TextObject::point(vectorcraft_geom::Point::new(20.0, 80.0), "D", plain.clone()); + for t in [marked, next] { + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let out = write(&doc); + assert!(!out.warnings.iter().any(|w| w == TYPE || w == TYPE_ATTRIBUTES), "{:?}", out.warnings); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + assert_eq!((text.matches("1 TY\n").count(), text.matches("0 TY\n").count()), (2, 2), "{text}"); + assert_eq!((text.matches("1 Tv\n").count(), text.matches("0 Tv\n").count()), (1, 1), "{text}"); + // The last block ends before its text object does. + assert!(text.contains("(34) Tx\n0 TY\nTO\n"), "{text}"); + let read = texts(&crate::import(&out.bytes).unwrap().document); + let flags = |t: &TextObject| -> Vec<(String, bool, bool)> { + let mut out: Vec<(String, bool, bool)> = vec![]; + for r in &t.runs { + let key = (r.style.tate_chu_yoko, r.style.standard_vertical_roman); + match out.last_mut() { + Some(last) if (last.1, last.2) == key => last.0.push_str(&r.text), + _ => out.push((r.text.clone(), key.0, key.1)), + } + } + out + }; + let s = |x: &str| x.to_string(); + assert_eq!( + flags(&read[0].1), + [(s("A"), false, false), (s("12"), true, false), (s("b"), false, true), (s("c"), true, true), (s("34"), true, false)] + ); + assert_eq!(flags(&read[1].1), [(s("D"), false, false)]); + + // Type with neither mark has neither operator. + let text = String::from_utf8(write(&typed()).bytes).unwrap(); + assert!(!text.contains(" Tv\n") && !text.contains(" TY\n"), "{text}"); +} + /// A legacy `.ai` text path holds the path and where the type starts on it, and no Align to Path /// or end bracket: type on a path with either is written as text, with the warning that they are /// left out. An end bracket at the end of an open path, or once around a closed one, is kept. A diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 8f8fc4ccf2..9bcd5f4f5d 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -39,6 +39,10 @@ pub(super) struct Writer<'a> { in_hidden: bool, /// The fonts type is set in: PostScript name, and whether it's TrueType. pub(super) fonts: Vec<(String, bool)>, + /// The Standard Vertical Roman Alignment the last `Tv` set (it lasts into later text objects). + roman: bool, + /// Inside a Kumi block `1 TY` began (Tate-chu-yoko in horizontal type). + kumi: bool, } impl<'a> Writer<'a> { @@ -59,6 +63,8 @@ impl<'a> Writer<'a> { hidden_art: vec![], in_hidden: false, fonts: vec![], + roman: false, + kumi: false, } } @@ -815,6 +821,10 @@ impl<'a> Writer<'a> { self.line(" Tx"); } } + if self.kumi { + self.line("0 TY"); + self.kumi = false; + } self.line("TO"); } @@ -834,7 +844,8 @@ impl<'a> Writer<'a> { } /// A run's character attributes and paint style: `Tr`, its fill and stroke, `Tf`, `Ts`, `Tz`, - /// `Tt`, `TA`, and the leading with the space before its paragraph, `Tl`. + /// `Tt`, `TA`, and the leading with the space before its paragraph, `Tl`; then `Tv` and `TY` + /// where Standard Vertical Roman Alignment or Tate-chu-yoko changes. fn char_style(&mut self, st: &CharStyle, p: &ParaStyle) { let fill = !st.fill.is_none(); let stroke = st.has_stroke(); @@ -872,6 +883,14 @@ impl<'a> Writer<'a> { let leading = st.leading.unwrap_or_else(|| p.justification.auto_leading_for(st.size)); ps::push_nums(&mut self.body, &[finite(leading, size * 1.2), finite(p.space_before, 0.0)]); self.line("Tl"); + if st.standard_vertical_roman != self.roman { + self.roman = st.standard_vertical_roman; + self.line(if self.roman { "1 Tv" } else { "0 Tv" }); + } + if st.tate_chu_yoko != self.kumi { + self.kumi = st.tate_chu_yoko; + self.line(if self.kumi { "1 TY" } else { "0 TY" }); + } } /// Type as its glyphs' outlines: a group of a compound path for each run, with the run's fill diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 23a36a7e9a..2f2c88abeb 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -155,7 +155,8 @@ private editing data. VectorCraft: under them, clipped to where they show, along a smooth outline traced at the image's own resolution; that art stays) or omitted (Transparent Images, `transparentImages: "flatten"|"omit"`). Type is written as editable text objects (point type, area type and type on a path, with their fonts' PostScript names, sizes, colours, alignment, indents, leading, tracking and - scaling), which the reading app sets in those fonts; characters outside Windows-1252, vertical type, ruby, inline + scaling, and the Standard Vertical Roman Alignment and Tate-chu-yoko marks as `Tv` and `TY`), which the reading + app sets in those fonts; characters outside Windows-1252, vertical type, ruby, inline graphics and gradient or pattern fills make that type outlines, and so do rotated characters (Character Rotation, which a version 7 text object can't hold: the outlines keep the rotation, and the type is no longer editable text in the file), and Text: Outlines (`outlineText: true`) writes From e6d5c8846e541765685af8ce18a5961add833d98 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:52:51 -0800 Subject: [PATCH 23/79] Color: Sample Color from Screen works on Linux through the desktop portal On Linux, ui.sampleScreenColor and the Color Picker's eyedropper button now show the XDG desktop portal's color sampler (Screenshot.PickColor, https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Screenshot.html). A thread of its own makes the call and waits for the person, and the picked sRGB color goes through the same conversion into the working RGB space as on macOS and the web. A cancel leaves the colors as they were. A session without a portal that has PickColor, a portal that does not return from the call within 25 seconds (VectorCraft's own limit) or leaves the bus before it answers, and a portal that ends the request in another way each show a status message and end the wait. The two new messages are translated in every catalog that has the sampler's other messages. The request token is random, as the Request documentation asks (https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html). The window is named for the portal on the UI thread, as for the save dialog, and the sampler's thread keeps the window and its export until the portal answers. --- Cargo.lock | 1 + apps/vectorcraft/Cargo.toml | 3 + apps/vectorcraft/src/host_services.rs | 10 +- apps/vectorcraft/src/linux_screen_color.rs | 42 +++ .../src/linux_screen_color/portal.rs | 330 ++++++++++++++++++ .../src/linux_screen_color/request.rs | 127 +++++++ apps/vectorcraft/src/main.rs | 2 + apps/vectorcraft/src/portal_save.rs | 7 + crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/screen_color.rs | 11 +- crates/ui-egui/src/services.rs | 7 +- docs/control-protocol.md | 2 +- docs/mcp.md | 13 + docs/target-app-parity.md | 2 +- 41 files changed, 598 insertions(+), 15 deletions(-) create mode 100644 apps/vectorcraft/src/linux_screen_color.rs create mode 100644 apps/vectorcraft/src/linux_screen_color/portal.rs create mode 100644 apps/vectorcraft/src/linux_screen_color/request.rs diff --git a/Cargo.lock b/Cargo.lock index 02e109b6b2..f9c3dacaa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6326,6 +6326,7 @@ dependencies = [ "craft-window", "eframe", "egui", + "futures-lite", "image", "log", "muda", diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 047c4530c7..55b9e344e7 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -107,6 +107,9 @@ wayland-protocols = { version = "0.32", features = ["client", "unstable"] } # wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build # through winit and rfd. wayland-backend = { version = "0.3", features = ["client_system"] } +# Waits for the screen color sampler's portal answer and for the portal leaving the bus at the same +# time (src/linux_screen_color/request.rs). Apache-2.0 OR MIT, already in the build through zbus. +futures-lite = "2" # The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the # systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 50f1792eb8..39b379b6f8 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -1,10 +1,10 @@ #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] use super::portal_save; -#[cfg(target_os = "linux")] -use super::tablet; #[cfg(all(windows, not(target_vendor = "win7")))] use super::windows_stylus; use super::{ClipboardProbeFactory, FilePick, PickRequest, Services, clipboard, cursor_theme, fileio, printing}; +#[cfg(target_os = "linux")] +use super::{linux_screen_color, tablet}; #[cfg(target_os = "macos")] use super::{mac_save_format, mac_screen_color, mac_tablet}; @@ -354,8 +354,10 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { .is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_))); let (clip, clip_probe) = (parent.clone(), parent.clone()); let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent); - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] let sampled_ui = ui.clone(); + #[cfg(target_os = "linux")] + let sampled_parent = p1.clone(); Services { pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())), pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))), @@ -406,6 +408,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { // Sample Color from Screen: the system's color sampler. #[cfg(target_os = "macos")] screen_color: Some(mac_screen_color::sampler(sampled_ui)), + #[cfg(target_os = "linux")] + screen_color: Some(linux_screen_color::sampler(sampled_ui, sampled_parent)), // File → Print: the system's printers and print queue. print: Some(Box::new(printing::SystemPrint)), ..Default::default() diff --git a/apps/vectorcraft/src/linux_screen_color.rs b/apps/vectorcraft/src/linux_screen_color.rs new file mode 100644 index 0000000000..0a07f2053c --- /dev/null +++ b/apps/vectorcraft/src/linux_screen_color.rs @@ -0,0 +1,42 @@ +//! Linux: Sample Color from Screen (`vectorcraft_ui_egui::screen_color`) through the XDG desktop +//! portal's color sampler ([`portal`]). A thread of its own asks the portal and waits for the +//! person, so the UI keeps drawing; it sends the color to the UI and wakes the window. The dialog's +//! parent is the window, named on the UI thread as for the portal's save dialog (`portal_save`). + +mod portal; +mod request; + +/// The portal's color sampler over the window `parent` holds; `ui` is woken when it answers. +pub fn sampler(ui: egui::Context, parent: super::host_services::Parent) -> vectorcraft_ui_egui::screen_color::StartSampler { + Box::new(move || { + let (tx, rx) = std::sync::mpsc::channel(); + let (ui, window) = (ui.clone(), parent.clone()); + // The thread holds the window until the export that names it is dropped. + let (handle, export) = match window.as_deref() { + Some(window) => { + let (handle, export) = super::portal_save::exported_parent(window); + (handle, Some(export)) + } + None => (String::new(), None), + }; + std::thread::Builder::new() + .name("screen-color".into()) + .spawn(move || { + let picked = portal::connect(zbus::blocking::connection::Builder::session()).and_then(|conn| portal::pick(&conn, &handle)); + drop(export); + drop(window); + let answer = picked.map_err(|failure| { + log::warn!("the portal's screen color sampler gave no color: {}", failure.detail()); + failure.message().to_string() + }); + // The UI may be gone (the app quitting): nothing waits for the color then. + let _ = tx.send(answer); + ui.request_repaint(); + }) + .map_err(|e| { + log::warn!("could not start the screen color sampler's thread: {e}"); + "the screen color sampler failed".to_string() + })?; + Ok(rx) + }) +} diff --git a/apps/vectorcraft/src/linux_screen_color/portal.rs b/apps/vectorcraft/src/linux_screen_color/portal.rs new file mode 100644 index 0000000000..4144a373cb --- /dev/null +++ b/apps/vectorcraft/src/linux_screen_color/portal.rs @@ -0,0 +1,330 @@ +//! The XDG desktop portal's `Screenshot.PickColor` +//! (): +//! the call returns a request path at once, and the request's `Response` signal later carries the +//! color the person picked, as sRGB components from 0 to 1, or a code for a cancel or another end. + +use std::collections::HashMap; + +use zbus::blocking::{Connection, Proxy}; +use zbus::zvariant::{OwnedValue, Value}; + +/// How long the portal has to return a request path for `PickColor`. VectorCraft's own limit, +/// 25 seconds as libdbus's default for a method call. The portal returns the path before it shows +/// the sampler, so the person's time with the sampler does not count. +const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); + +/// Why no color came back, as the status bar shows it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Failure { + /// No session bus, no portal, or a portal without the Screenshot interface's `PickColor`. + Unavailable(String), + /// The portal did not return a request path in time, or it left the bus before it answered. + NoAnswer(String), + /// The portal ended the request without a color, or its answer can't be read. + Failed(String), +} + +impl Failure { + /// The status bar's message (English; the status bar translates it). + pub(super) fn message(&self) -> &'static str { + match self { + Failure::Unavailable(_) => "this system has no screen color sampler", + Failure::NoAnswer(_) => "the screen color sampler did not answer", + Failure::Failed(_) => "the screen color sampler failed", + } + } + + /// What went wrong, for the log. + pub(super) fn detail(&self) -> &str { + match self { + Failure::Unavailable(d) | Failure::NoAnswer(d) | Failure::Failed(d) => d, + } + } +} + +/// D-Bus errors that mean the session has no portal, or none that answers `PickColor`. +const UNAVAILABLE: &[&str] = &[ + "org.freedesktop.DBus.Error.ServiceUnknown", + "org.freedesktop.DBus.Error.NameHasNoOwner", + "org.freedesktop.DBus.Error.UnknownMethod", + "org.freedesktop.DBus.Error.UnknownInterface", + "org.freedesktop.DBus.Error.UnknownObject", +]; + +/// The connection `builder` makes, with [`CALL_TIMEOUT`] for its method calls. +pub(super) fn connect(builder: zbus::Result>) -> Result { + builder.and_then(|b| b.method_timeout(CALL_TIMEOUT).build()).map_err(|e| Failure::Unavailable(e.to_string())) +} + +/// Shows the portal's color sampler over the window `parent` names and waits for the color, `None` +/// when the person canceled it. `conn`'s method timeout bounds the call; the wait for the person +/// after it has no bound, and ends when the portal answers or leaves the bus. +pub(super) fn pick(conn: &Connection, parent: &str) -> Result, Failure> { + const DESKTOP: &str = "org.freedesktop.portal.Desktop"; + let token = super::request::token("vectorcraft_color").map_err(Failure::Failed)?; + // Made before the call: a portal can send its answer before the call returns. + let replies = super::request::Responses::new(conn).map_err(Failure::Failed)?; + let proxy = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Screenshot") + .map_err(|e| Failure::Failed(e.to_string()))?; + let options = HashMap::from([("handle_token", Value::from(token.as_str()))]); + let call = proxy.call_method("PickColor", &(parent, options)).map_err(|e| call_failure(&e))?; + let reply = replies.wait(&call).map_err(Failure::NoAnswer)?; + let (code, results): (u32, HashMap) = reply.body().deserialize().map_err(|e| Failure::Failed(e.to_string()))?; + answer(code, &results) +} + +/// The [`Failure`] for an error the `PickColor` call returned. zbus reports a call that outlives +/// the connection's method timeout as an `InputOutput` error of kind `TimedOut`. +fn call_failure(e: &zbus::Error) -> Failure { + match e { + zbus::Error::MethodError(name, ..) if UNAVAILABLE.contains(&name.as_str()) => Failure::Unavailable(e.to_string()), + zbus::Error::InputOutput(io) if io.kind() == std::io::ErrorKind::TimedOut => Failure::NoAnswer(e.to_string()), + _ => Failure::Failed(e.to_string()), + } +} + +/// The color in a `Response` with `code` and `results`: response 0 carries it, 1 is a cancel, and +/// any other code ends the request without one. The components are passed on as given; +/// `screen_color::sampled` clamps them to 0 to 1, as for the other systems' samplers. +fn answer(code: u32, results: &HashMap) -> Result, Failure> { + match code { + 0 => {} + 1 => return Ok(None), + _ => return Err(Failure::Failed(format!("the portal ended the request (response {code})"))), + } + let color = results.get("color").ok_or_else(|| Failure::Failed("no color in the portal's answer".into()))?; + // Matched field by field: zvariant's tuple conversion panics on a structure with fewer fields. + match &**color { + Value::Structure(s) => match s.fields() { + [Value::F64(r), Value::F64(g), Value::F64(b)] => Ok(Some([*r, *g, *b].map(|v| v as f32))), + _ => Err(Failure::Failed(format!("the portal's color is not three numbers: {color:?}"))), + }, + _ => Err(Failure::Failed(format!("the portal's color is not three numbers: {color:?}"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn color(rgb: (f64, f64, f64)) -> HashMap { + HashMap::from([("color".into(), OwnedValue::try_from(Value::from(rgb)).unwrap())]) + } + + fn failed(r: Result) -> bool { + matches!(r, Err(Failure::Failed(_))) + } + + #[test] + fn a_picked_color_keeps_its_srgb_values() { + assert_eq!(answer(0, &color((0.25, 0.5, 0.75))), Ok(Some([0.25, 0.5, 0.75]))); + assert_eq!(answer(0, &color((0.0, 1.0, 0.0))), Ok(Some([0.0, 1.0, 0.0]))); + // Outside 0 to 1: passed on for `screen_color::sampled` to clamp. + assert_eq!(answer(0, &color((-0.5, 1.5, 1.0))), Ok(Some([-0.5, 1.5, 1.0]))); + } + + #[test] + fn a_cancel_needs_no_color() { + assert_eq!(answer(1, &HashMap::new()), Ok(None)); + assert_eq!(answer(1, &color((0.25, 0.5, 0.75))), Ok(None)); + } + + #[test] + fn other_response_codes_are_failures() { + for code in [2, 3, u32::MAX] { + assert!(failed(answer(code, &color((0.25, 0.5, 0.75)))), "{code}"); + } + } + + #[test] + fn a_success_without_a_valid_color_is_a_failure() { + assert!(failed(answer(0, &HashMap::new()))); + let wrong = HashMap::from([("color".into(), OwnedValue::from(1_u32))]); + assert!(failed(answer(0, &wrong))); + // Fewer fields, more fields, and fields of another type: an error, not a panic. + for wrong in [Value::from((0.5, 0.5)), Value::from((0.5, 0.5, 0.5, 0.5)), Value::from((1_u32, 0.5, 0.5)), Value::from(vec![0.5, 0.5, 0.5])] { + let wrong = HashMap::from([("color".into(), OwnedValue::try_from(wrong).unwrap())]); + assert!(failed(answer(0, &wrong))); + } + } + + #[test] + fn call_errors_say_whether_the_system_has_a_sampler() { + let reply = |name: &str| { + let call = zbus::Message::method_call("/org/freedesktop/portal/desktop", "PickColor").unwrap().build(&()).unwrap(); + let error = zbus::Message::error(&call.header(), name).unwrap().build(&("detail",)).unwrap(); + zbus::Error::from(error) + }; + for name in UNAVAILABLE { + assert!(matches!(call_failure(&reply(name)), Failure::Unavailable(_)), "{name}"); + } + assert!(matches!(call_failure(&reply("org.freedesktop.portal.Error.NotAllowed")), Failure::Failed(_))); + let timed_out = zbus::Error::InputOutput(std::sync::Arc::new(std::io::ErrorKind::TimedOut.into())); + assert!(matches!(call_failure(&timed_out), Failure::NoAnswer(_))); + let broken = zbus::Error::InputOutput(std::sync::Arc::new(std::io::ErrorKind::BrokenPipe.into())); + assert!(matches!(call_failure(&broken), Failure::Failed(_))); + let messages = [Failure::Unavailable(String::new()), Failure::NoAnswer(String::new()), Failure::Failed(String::new())].map(|f| f.message()); + assert_eq!( + messages, + ["this system has no screen color sampler", "the screen color sampler did not answer", "the screen color sampler failed"] + ); + assert_eq!(call_failure(&broken).detail(), broken.to_string()); + } + + /// Every language that translates the sampler's other messages translates these too. The + /// i18n tests of `vectorcraft-ui-egui` do not read this crate's messages. + #[test] + fn the_messages_are_translated_where_the_sampler_is() { + use vectorcraft_ui_egui::i18n::{Lang, message}; + const OPEN: &str = "the screen color sampler is already open"; + let langs: Vec = Lang::all().filter(|&lang| message(lang, OPEN) != OPEN).collect(); + assert!(!langs.is_empty(), "no language translates {OPEN:?}"); + for lang in langs { + for failure in [Failure::Unavailable(String::new()), Failure::NoAnswer(String::new()), Failure::Failed(String::new())] { + assert_ne!(message(lang, failure.message()), failure.message(), "{}", lang.code()); + } + } + } + + /// A session bus of its own (`dbus-daemon`), `None` when there is none to run. + fn private_bus() -> Option<(Daemon, String)> { + use std::io::BufRead as _; + let spawning = crate::spawning(); + let daemon = std::process::Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(std::process::Stdio::piped()) + .spawn(); + drop(spawning); + let Ok(daemon) = daemon else { + eprintln!("no dbus-daemon: skipped"); + return None; + }; + let mut daemon = Daemon(daemon); + let mut address = String::new(); + std::io::BufReader::new(daemon.0.stdout.take().unwrap()).read_line(&mut address).unwrap(); + Some((daemon, address.trim().to_string())) + } + + /// Stops the private bus when the test ends, unwinding included. + struct Daemon(std::process::Child); + + impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + /// The client's connection to the private bus at `address`, made as the sampler makes its own. + fn client(address: &str) -> Connection { + connect(zbus::blocking::connection::Builder::address(address)).unwrap() + } + + /// [`pick`] on `conn`, given 30 seconds to answer: an answer that is never awaited fails the + /// test instead of leaving it waiting. + fn pick_within(conn: &Connection, parent: &str) -> Result, Failure> { + let (tx, rx) = std::sync::mpsc::channel(); + let (conn, parent) = (conn.clone(), parent.to_string()); + std::thread::spawn(move || { + let _ = tx.send(pick(&conn, &parent)); + }); + rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the portal's answer was not awaited within 30 seconds") + } + + /// The portal's name, owned on the private bus at `address` before any call, so the bus never + /// starts an installed portal in its place. + fn portal(address: &str) -> Connection { + zbus::blocking::connection::Builder::address(address).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap() + } + + /// Through a real D-Bus: an answer that comes before the call returns is kept; answers on + /// other request paths or from other connections are ignored; a cancel gives no color; and a + /// portal that leaves the bus while the person picks ends the wait. + #[test] + fn the_answer_is_the_portals_response_on_the_returned_path() { + use zbus::blocking::MessageIterator; + use zbus::zvariant::OwnedObjectPath; + + let Some((_daemon, address)) = private_bus() else { return }; + let address = address.as_str(); + let server = portal(address); + let impostor = zbus::blocking::connection::Builder::address(address).unwrap().build().unwrap(); + let incoming = MessageIterator::from(&server); + let worker = std::thread::spawn(move || { + let mut calls = Vec::new(); + for msg in incoming { + let msg = msg.unwrap(); + let header = msg.header(); + if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("PickColor") { + continue; + } + let (parent, options): (String, HashMap) = msg.body().deserialize().unwrap(); + let token = String::try_from(options.get("handle_token").unwrap().try_clone().unwrap()).unwrap(); + assert!(token.starts_with("vectorcraft_color_")); + assert!(token.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')); + assert!(!calls.iter().any(|(_, old)| old == &token)); + calls.push((parent, token)); + // A portal older than version 0.9 answers on a path of its own, not the token's. + let path = format!("/org/freedesktop/portal/desktop/request/independent/pick_{}", calls.len()); + let handle = OwnedObjectPath::try_from(path.as_str()).unwrap(); + if calls.len() == 4 { + server.reply(&header, &handle).unwrap(); + assert!(server.release_name("org.freedesktop.portal.Desktop").unwrap()); + break; + } + let to = header.sender().cloned().map(zbus::names::BusName::from); + // Another connection can't end the wait by claiming the portal left. + impostor + .emit_signal( + to.clone(), + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameOwnerChanged", + &("org.freedesktop.portal.Desktop", server.unique_name().unwrap().as_str(), ""), + ) + .unwrap(); + let wrong = color((1.0, 0.0, 0.0)); + impostor.emit_signal(to.clone(), path.as_str(), "org.freedesktop.portal.Request", "Response", &(0_u32, &wrong)).unwrap(); + let unrelated = "/org/freedesktop/portal/desktop/request/unrelated"; + server.emit_signal(to.clone(), unrelated, "org.freedesktop.portal.Request", "Response", &(0_u32, &wrong)).unwrap(); + let code = if calls.len() == 2 { 1_u32 } else { 0_u32 }; + server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, &color((0.25, 0.5, 0.75)))).unwrap(); + // The reply that names the path goes last: every answer above came before it. + server.reply(&header, &handle).unwrap(); + } + calls + }); + let client = client(address); + assert_eq!(pick_within(&client, "x11:12"), Ok(Some([0.25, 0.5, 0.75]))); + assert_eq!(pick_within(&client, "wayland:example"), Ok(None)); + assert_eq!(pick_within(&client, ""), Ok(Some([0.25, 0.5, 0.75]))); + assert!(matches!(pick_within(&client, ""), Err(Failure::NoAnswer(_)))); + let calls = worker.join().unwrap(); + assert_eq!(calls.iter().map(|(parent, _)| parent.as_str()).collect::>(), ["x11:12", "wayland:example", "", ""]); + } + + /// Through a real D-Bus: a portal without `PickColor` ends the request as a system without a + /// sampler, and the sampler's connection gives the call [`CALL_TIMEOUT`]. + #[test] + fn a_portal_without_pick_color_ends_the_request() { + let Some((_daemon, address)) = private_bus() else { return }; + let address = address.as_str(); + let server = portal(address); + let incoming = zbus::blocking::MessageIterator::from(&server); + let worker = std::thread::spawn(move || { + for msg in incoming { + let msg = msg.unwrap(); + let header = msg.header(); + if header.member().map(|m| m.as_str()) == Some("PickColor") { + server.reply_error(&header, "org.freedesktop.DBus.Error.UnknownMethod", &("no PickColor",)).unwrap(); + break; + } + } + }); + let client = client(address); + assert_eq!(client.method_timeout(), Some(CALL_TIMEOUT)); + let failure = pick_within(&client, ""); + assert!(matches!(failure, Err(Failure::Unavailable(_))), "{failure:?}"); + worker.join().unwrap(); + } +} diff --git a/apps/vectorcraft/src/linux_screen_color/request.rs b/apps/vectorcraft/src/linux_screen_color/request.rs new file mode 100644 index 0000000000..f442b840a3 --- /dev/null +++ b/apps/vectorcraft/src/linux_screen_color/request.rs @@ -0,0 +1,127 @@ +//! The answer to the color sampler's XDG desktop portal request +//! (). +//! The wait ends with the request's `Response`, or with an error when the portal leaves the bus +//! first. + +use std::io::Read as _; + +use futures_lite::{StreamExt as _, future}; +use zbus::blocking::{Connection, MessageIterator}; +use zbus::zvariant::OwnedObjectPath; + +const DESKTOP: &str = "org.freedesktop.portal.Desktop"; + +/// A request token: `prefix`, then 128 bits from the operating system's random device, so the +/// token is unique and can't be guessed, as the Request documentation asks. +pub(super) fn token(prefix: &str) -> Result { + let mut bytes = [0; 16]; + std::fs::File::open("/dev/urandom") + .and_then(|mut file| file.read_exact(&mut bytes)) + .map_err(|e| format!("could not create a portal request token: {e}"))?; + let random: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + Ok(format!("{prefix}_{random}")) +} + +/// Subscriptions made before a portal method call, on every request path: a portal older than +/// version 0.9 ignores `handle_token` and answers on the request path it returns, and its answer +/// can come before the call's reply. Each stream keeps every signal from the time it is made. +pub(super) struct Responses { + replies: MessageIterator, + owners: MessageIterator, +} + +impl Responses { + pub(super) fn new(conn: &Connection) -> Result { + let reply_rule = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender(DESKTOP) + .and_then(|b| b.interface("org.freedesktop.portal.Request")) + .and_then(|b| b.member("Response")) + .map_err(|e| e.to_string())? + .build(); + let owner_rule = zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender("org.freedesktop.DBus") + .and_then(|b| b.path("/org/freedesktop/DBus")) + .and_then(|b| b.interface("org.freedesktop.DBus")) + .and_then(|b| b.member("NameOwnerChanged")) + .and_then(|b| b.arg(0, DESKTOP)) + .map_err(|e| e.to_string())? + .build(); + Ok(Self { + replies: MessageIterator::for_match_rule(reply_rule, conn, None).map_err(|e| e.to_string())?, + owners: MessageIterator::for_match_rule(owner_rule, conn, None).map_err(|e| e.to_string())?, + }) + } + + /// The `Response` on the request path `call` returned, from the connection that returned it. + /// An error when that connection leaves the bus first, or the bus connection closes. + pub(super) fn wait(self, call: &zbus::Message) -> Result { + enum Event { + Reply(Option>), + Owner(Option>), + } + let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?; + let portal = call.header().sender().map(|s| s.as_str().to_string()).ok_or("the portal answered without a sender")?; + let mut replies = self.replies.into_inner(); + let mut owners = self.owners.into_inner(); + future::block_on(async { + loop { + let event = future::or(async { Event::Reply(replies.next().await) }, async { Event::Owner(owners.next().await) }).await; + match event { + Event::Reply(reply) => { + let reply = reply.ok_or("the portal closed without an answer")?.map_err(|e| e.to_string())?; + let header = reply.header(); + if header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == Some(portal.as_str()) + { + return Ok(reply); + } + } + Event::Owner(message) => { + let message = message.ok_or("the portal connection closed")?.map_err(|e| e.to_string())?; + if owner_disappeared(&message, &portal)? { + return Err("the desktop portal disappeared while waiting for its answer".into()); + } + } + } + } + }) + } +} + +/// Whether `message` is the bus's `NameOwnerChanged` saying that the connection `portal` no longer +/// owns the portal's name. The bus itself must send it: another connection can't end the wait. +fn owner_disappeared(message: &zbus::Message, portal: &str) -> Result { + if message.header().sender().map(|s| s.as_str()) != Some("org.freedesktop.DBus") { + return Ok(false); + } + let (name, old, new): (String, String, String) = message.body().deserialize().map_err(|e| e.to_string())?; + Ok(name == DESKTOP && old == portal && new != portal) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_bus_can_report_portal_owner_loss() { + let changed = |sender, name, old, new| { + zbus::Message::signal("/org/freedesktop/DBus", "org.freedesktop.DBus", "NameOwnerChanged") + .unwrap() + .sender(sender) + .unwrap() + .build(&(name, old, new)) + .unwrap() + }; + let forged = changed(":1.42", DESKTOP, ":1.7", ""); + assert!(!owner_disappeared(&forged, ":1.7").unwrap()); + let real = changed("org.freedesktop.DBus", DESKTOP, ":1.7", ""); + assert!(owner_disappeared(&real, ":1.7").unwrap()); + let other = changed("org.freedesktop.DBus", "org.example.Other", ":1.7", ""); + assert!(!owner_disappeared(&other, ":1.7").unwrap()); + let old = changed("org.freedesktop.DBus", DESKTOP, ":1.8", ""); + assert!(!owner_disappeared(&old, ":1.7").unwrap()); + let acquired = changed("org.freedesktop.DBus", DESKTOP, "", ":1.7"); + assert!(!owner_disappeared(&acquired, ":1.7").unwrap()); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 33e03b1f72..39f86f2973 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -32,6 +32,8 @@ mod cursor_theme; mod gpu; mod hang_watch; #[cfg(target_os = "linux")] +mod linux_screen_color; +#[cfg(target_os = "linux")] mod linux_stylus; mod logging; #[cfg(all(target_os = "macos", feature = "wgpu"))] diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index 0357829aec..655bfa4e39 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -233,6 +233,13 @@ fn parent(window: &winit::window::Window) -> (String, Option) { } } +/// [`parent`] for another dialog of the portal (the screen color sampler, `linux_screen_color`): +/// the identifier stays valid until the second value is dropped. +#[cfg(target_os = "linux")] +pub(crate) fn exported_parent(window: &winit::window::Window) -> (String, impl Send + 'static) { + parent(window) +} + /// The portal's identifier of `window`: its X11 id, else empty (no Wayland export is made off /// Linux). #[cfg(any(target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index ee7a5f0ced..96dcaa0aa7 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -783,7 +783,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.sampleScreenColor", "Sample Color from Screen", "", - "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; Escape cancels it and the colors stay as they were. ui.inspect reports `screenSampler: true` until it answers. An error without a sampler, while one is open, or with `picker` and no Color Picker open", + "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; Linux through the XDG desktop portal's PickColor; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; a cancel (Escape on macOS and in browsers) leaves the colors as they were. ui.inspect reports `screenSampler: true` until it answers; on Linux, a session without a portal that has PickColor, a portal that does not return from the call within 25 seconds (VectorCraft's own limit) or leaves the bus before it answers, and a portal that ends the request another way end the wait with a status message. An error without a sampler, while one is open, or with `picker` and no Color Picker open", ), ( "ui.newBrush", diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f6029f08ef..6c7b07ba52 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4584,6 +4584,8 @@ imageTrace Outline Obris @msg the Smooth slider is not open klizač Izgladi nije otvoren @msg the screen color sampler is already open uzorkivač boja s ekrana je već otvoren @msg this system has no screen color sampler ovaj sistem nema uzorkivač boja s ekrana +@msg the screen color sampler did not answer uzorkivač boja s ekrana nije odgovorio +@msg the screen color sampler failed uzorkivač boja s ekrana nije uspio @msg bad colour `{hex}` (#rrggbb) loša boja `{hex}` (#rrggbb) @msg bad page tree loše stablo stranica @msg bad shortcut loša prečica diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cf0b677271..bf14c6c91a 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5985,6 +5985,8 @@ digits Malayalam Malaiàlam @msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it aquest fitxer PostScript no es pot llegir ({why}): desa’l com a PDF o SVG a l’aplicació que l’ha creat @msg This shortcut file is in a form VectorCraft can't read. Aquest fitxer de dreceres té un format que VectorCraft no pot llegir. @msg this system has no screen color sampler aquest sistema no té cap mostrejador de color de pantalla +@msg the screen color sampler did not answer el mostrejador de color de pantalla no ha respost +@msg the screen color sampler failed el mostrejador de color de pantalla ha fallat @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset aquesta traça crearia més de {max} punts d’ancoratge, més dels que el document pot mostrar amb fluïdesa: augmenta el Soroll, redueix Traçats o Colors, o tria un preajust de fidelitat més baixa @msg thumbnail: no page {n} (the file has 1) miniatura: no hi ha la pàgina {n} (el fitxer en té 1) @msg tintReduction must be from 0 to 100 % tintReduction ha de ser d’entre 0 i 100 % diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9125a4f05f..34cffad2cd 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4660,6 +4660,8 @@ imageTrace Outline Obrys @msg the Smooth slider is not open Posuvník Vyhladit není otevřený @msg the screen color sampler is already open nástroj pro nabírání barvy z obrazovky už je otevřený @msg this system has no screen color sampler tento systém nemá nástroj pro nabírání barvy z obrazovky +@msg the screen color sampler did not answer nástroj pro nabírání barvy z obrazovky neodpověděl +@msg the screen color sampler failed nástroj pro nabírání barvy z obrazovky selhal @msg bad colour `{hex}` (#rrggbb) chybná barva `{hex}` (#rrggbb) @msg bad page tree chybný strom stránek @msg bad shortcut chybná klávesová zkratka diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 00b46b0f22..8de9db6a24 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5492,6 +5492,8 @@ mark-style Japanese Japansk @msg the Color Picker is not open farvevælgeren er ikke åben @msg the screen color sampler is already open skærmfarvesampleren er allerede åben @msg this system has no screen color sampler dette system har ingen skærmfarvesampler +@msg the screen color sampler did not answer skærmfarvesampleren svarede ikke +@msg the screen color sampler failed skærmfarvesampleren mislykkedes swatch [None] [Ingen] swatch [Registration] [Passning] swatch White Hvid diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ec8d91ad1c..206edad169 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4275,6 +4275,8 @@ theme Appearance Erscheinungsbild @msg the Smooth slider is not open der Regler „Glätten“ ist nicht geöffnet @msg the screen color sampler is already open die Farbaufnahme vom Bildschirm ist bereits geöffnet @msg this system has no screen color sampler dieses System hat keine Farbaufnahme vom Bildschirm +@msg the screen color sampler did not answer die Farbaufnahme vom Bildschirm hat nicht geantwortet +@msg the screen color sampler failed die Farbaufnahme vom Bildschirm ist fehlgeschlagen @msg bad colour `{hex}` (#rrggbb) ungültige Farbe `{hex}` (#rrggbb) @msg bad page tree fehlerhafter Seitenbaum @msg bad shortcut ungültiger Tastaturbefehl diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 17527f5871..3be9788e2f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5816,6 +5816,8 @@ effect Points Σημεία @msg the screen color sampler is already open η δειγματοληψία χρώματος από την οθόνη είναι ήδη ανοιχτή @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS αυτό είναι αρχείο PostScript (ένα .ai αποθηκευμένο σε παλαιότερη μορφή ή χωρίς συμβατότητα PDF, ή αρχείο EPS), όχι PDF: οι εντολές Αρχείο › Άνοιγμα και Αρχείο › Τοποθέτηση το διαβάζουν ως EPS @msg this system has no screen color sampler αυτό το σύστημα δεν έχει δειγματοληψία χρώματος από την οθόνη +@msg the screen color sampler did not answer η δειγματοληψία χρώματος από την οθόνη δεν απάντησε +@msg the screen color sampler failed η δειγματοληψία χρώματος από την οθόνη απέτυχε @msg the image can't be read as a texture η εικόνα δεν μπορεί να διαβαστεί ως υφή # User Interface › Accent Color diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f4ea5ecf80..4b9c550dfb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4351,6 +4351,8 @@ axis Both Ambos @msg the Smooth slider is not open el regulador de Suavizar no está abierto @msg the screen color sampler is already open el muestreador de color de la pantalla ya está abierto @msg this system has no screen color sampler este sistema no tiene muestreador de color de la pantalla +@msg the screen color sampler did not answer el muestreador de color de la pantalla no respondió +@msg the screen color sampler failed el muestreador de color de la pantalla falló @msg bad colour `{hex}` (#rrggbb) color no válido `{hex}` (#rrggbb) @msg bad page tree árbol de páginas no válido @msg bad shortcut atajo no válido diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b7a34058f4..1181b92e88 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5828,6 +5828,8 @@ mark-style Japanese Japanilainen @msg the screen color sampler is already open värin poiminta näytöltä on jo auki @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS tämä on PostScript-tiedosto (vanhemmassa muodossa tai ilman PDF-yhteensopivuutta tallennettu .ai tai EPS-tiedosto), ei PDF: Tiedosto › Avaa ja Tiedosto › Sijoita lukevat sen EPS-muodossa @msg this system has no screen color sampler tässä järjestelmässä ei ole värin poimintaa näytöltä +@msg the screen color sampler did not answer värin poiminta näytöltä ei vastannut +@msg the screen color sampler failed värin poiminta näytöltä epäonnistui @msg the image can't be read as a texture kuvaa ei voi lukea tekstuurina # User Interface › Accent Color diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 36bf674ef2..a9b5b5316c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4340,6 +4340,8 @@ axis Both Les deux @msg the Smooth slider is not open le curseur Lisser n’est pas ouvert @msg the screen color sampler is already open l’outil de prélèvement de couleur à l’écran est déjà ouvert @msg this system has no screen color sampler ce système n’a pas d’outil de prélèvement de couleur à l’écran +@msg the screen color sampler did not answer l’outil de prélèvement de couleur à l’écran n’a pas répondu +@msg the screen color sampler failed l’outil de prélèvement de couleur à l’écran a échoué @msg bad colour `{hex}` (#rrggbb) couleur non valide `{hex}` (#rrggbb) @msg bad page tree arborescence des pages non valide @msg bad shortcut raccourci non valide diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7dbeabac1e..fc46a9a084 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7861,6 +7861,8 @@ antiAlias Sharp शार्प @msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it इस पोस्टस्क्रिप्ट फ़ाइल को पढ़ा नहीं जा सकता ({why}): इसे उस ऐप में PDF या SVG के रूप में सेव करें जिसने इसे बनाया @msg This shortcut file is in a form VectorCraft can't read. यह शॉर्टकट फ़ाइल एक फॉर्म VectorCraft में है जिसे पढ़ा नहीं जा सकता है। @msg this system has no screen color sampler इस प्रणाली में कोई स्क्रीन रंग नमूना नहीं है +@msg the screen color sampler did not answer स्क्रीन रंग नमूने ने उत्तर नहीं दिया +@msg the screen color sampler failed स्क्रीन रंग नमूना विफल रहा @msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset यह निशान {max} एंकर पॉइंट से अधिक होगा, डॉक्यूमेंट की तुलना में अधिक आसानी से दिखा सकते हैं: शोर, निचले पाथ या रंग उठाना, या कम-fidelity प्रीसेट चुनना @msg thumbnail: no page {n} (the file has 1) थंबनेल: कोई पेज {n} (फ़ाइल में 1 है) @msg thumbnail: no page {v} (the {what} has {count}) थंबनेल: कोई पेज {v} ({what} में {count} है) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c87d42e6dc..1b76b3449f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4895,6 +4895,8 @@ imageTrace Outline Körvonal @msg the Smooth slider is not open a Simítás csúszka nincs megnyitva @msg the screen color sampler is already open a képernyős színmintavevő már nyitva van @msg this system has no screen color sampler ezen a rendszeren nincs képernyős színmintavevő +@msg the screen color sampler did not answer a képernyős színmintavevő nem válaszolt +@msg the screen color sampler failed a képernyős színmintavevő hibába ütközött @msg bad colour `{hex}` (#rrggbb) hibás szín: `{hex}` (#rrggbb) @msg bad page tree hibás oldalfa @msg bad shortcut hibás billentyűparancs diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9925f7b0d8..80f5b9df3f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5492,6 +5492,8 @@ mark-style Japanese Jepang @msg the Color Picker is not open Pemilih Warna tidak terbuka @msg the screen color sampler is already open pengambil warna layar sudah terbuka @msg this system has no screen color sampler sistem ini tidak memiliki pengambil warna layar +@msg the screen color sampler did not answer pengambil warna layar tidak merespons +@msg the screen color sampler failed pengambil warna layar gagal swatch [None] [Tidak ada] swatch [Registration] [Registrasi] swatch White Putih diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 36c8820296..fc3748d4cb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4402,6 +4402,8 @@ axis Both Entrambi @msg the Smooth slider is not open il cursore Smussa non è aperto @msg the screen color sampler is already open il campionatore di colore dello schermo è già aperto @msg this system has no screen color sampler questo sistema non ha un campionatore di colore dello schermo +@msg the screen color sampler did not answer il campionatore di colore dello schermo non ha risposto +@msg the screen color sampler failed il campionatore di colore dello schermo ha avuto un errore @msg bad colour `{hex}` (#rrggbb) colore non valido `{hex}` (#rrggbb) @msg bad page tree albero delle pagine non valido @msg bad shortcut scelta rapida non valida diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6dcabf27e8..48cea3268d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4756,6 +4756,8 @@ swatch Bright Violet ビビッドバイオレット @msg the Smooth slider is not open スムーズスライダーが開いていません @msg the screen color sampler is already open 画面のカラーサンプラーはすでに開いています @msg this system has no screen color sampler このシステムには画面のカラーサンプラーがありません +@msg the screen color sampler did not answer 画面のカラーサンプラーが応答しませんでした +@msg the screen color sampler failed 画面のカラーサンプラーでエラーが発生しました @msg Corners edits the corners of a path 「コーナー」で編集できるのはパスの角だけです @msg give corners of the path: anchors where its sides meet at an angle パスの角 (辺が角度をなして交わるアンカーポイント) を指定してください @msg select one path, or give its `id` パスを 1 つ選択するか、その `id` を指定してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 23a18557fa..31eba9542f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5414,6 +5414,8 @@ imageTrace Outline კონტური @msg the Smooth slider is not open გაგლუვების სლაიდერი გახსნილი არ არის @msg the screen color sampler is already open ეკრანიდან ფერის აღება უკვე გახსნილია @msg this system has no screen color sampler ამ სისტემას ეკრანიდან ფერის აღების საშუალება არ აქვს +@msg the screen color sampler did not answer ეკრანიდან ფერის აღების საშუალებამ არ უპასუხა +@msg the screen color sampler failed ეკრანიდან ფერის აღება ვერ მოხერხდა # Dimension tool and Object › Dimensions. A drafting dimension is ზომა (ზომის დასმა), like a dimension on a technical drawing. Dimension ზომა Drawing : real size — 1:10 labels 1 unit on the page as 10. ნახაზი : რეალური ზომა — 1:10 გვერდზე 1 ერთეულს 10-ად აწერს. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5366483cd9..56fa5aca9d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4683,6 +4683,8 @@ theme Appearance 외관 @msg the Smooth slider is not open 매끄럽게 슬라이더가 열려 있지 않습니다 @msg the screen color sampler is already open 화면 색상 추출기가 이미 열려 있습니다 @msg this system has no screen color sampler 이 시스템에는 화면 색상 추출기가 없습니다 +@msg the screen color sampler did not answer 화면 색상 추출기가 응답하지 않았습니다 +@msg the screen color sampler failed 화면 색상 추출기에 오류가 발생했습니다 @msg Corners edits the corners of a path 모퉁이는 패스의 모퉁이를 편집합니다 @msg give corners of the path: anchors where its sides meet at an angle 패스의 모퉁이, 즉 변이 각을 이루며 만나는 앵커를 지정하세요 @msg select one path, or give its `id` 패스를 하나 선택하거나 `id`를 지정하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 20ea6ac94d..e186959d18 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5500,6 +5500,8 @@ mark-style Japanese Japans @msg the Color Picker is not open de kleurenkiezer is niet open @msg the screen color sampler is already open de schermkleurkiezer is al open @msg this system has no screen color sampler dit systeem heeft geen schermkleurkiezer +@msg the screen color sampler did not answer de schermkleurkiezer heeft niet geantwoord +@msg the screen color sampler failed de schermkleurkiezer is mislukt swatch [None] [Geen] swatch [Registration] [Passers] swatch White Wit diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d72f58fa4..6b5386830d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4483,6 +4483,8 @@ imageTrace Outline Kontur @msg the Smooth slider is not open Suwak Wygładź nie jest otwarty @msg the screen color sampler is already open narzędzie do pobierania koloru z ekranu jest już otwarte @msg this system has no screen color sampler ten system nie ma narzędzia do pobierania koloru z ekranu +@msg the screen color sampler did not answer narzędzie do pobierania koloru z ekranu nie odpowiedziało +@msg the screen color sampler failed narzędzie do pobierania koloru z ekranu zgłosiło błąd @msg bad colour `{hex}` (#rrggbb) nieprawidłowy kolor `{hex}` (#rrggbb) @msg bad page tree nieprawidłowe drzewo stron @msg bad shortcut nieprawidłowy skrót diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index eb2c29947a..97a957e74c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5765,6 +5765,8 @@ mark-style Japanese Japonesas @msg the Smooth slider is not open o controle deslizante Suavizar não está aberto @msg the screen color sampler is already open o coletor de cores da tela já está aberto @msg this system has no screen color sampler este sistema não tem coletor de cores da tela +@msg the screen color sampler did not answer o coletor de cores da tela não respondeu +@msg the screen color sampler failed o coletor de cores da tela falhou Darkness Escuridão Dimension Cota Dimension Tool Options Opções da Ferramenta Cota diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 533192c53a..8721333157 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4388,6 +4388,8 @@ axis Both Оба @msg the Smooth slider is not open ползунок «Сгладить» не открыт @msg the screen color sampler is already open средство выбора цвета с экрана уже открыто @msg this system has no screen color sampler в этой системе нет средства выбора цвета с экрана +@msg the screen color sampler did not answer средство выбора цвета с экрана не ответило +@msg the screen color sampler failed в средстве выбора цвета с экрана произошла ошибка @msg bad colour `{hex}` (#rrggbb) недопустимый цвет `{hex}` (#rrggbb) @msg bad page tree недопустимое дерево страниц @msg bad shortcut недопустимое сочетание клавиш diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 558238e60e..2caa43deb4 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5819,6 +5819,8 @@ theme Appearance Utseende @msg the screen color sampler is already open färghämtningen från skärmen är redan öppen @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS det här är en PostScript-fil (en .ai sparad i ett äldre format eller utan PDF-kompatibilitet, eller en EPS-fil), inte en PDF: Fil › Öppna och Fil › Placera läser den som EPS @msg this system has no screen color sampler det här systemet saknar färghämtning från skärmen +@msg the screen color sampler did not answer färghämtningen från skärmen svarade inte +@msg the screen color sampler failed färghämtningen från skärmen misslyckades @msg the image can't be read as a texture bilden kan inte läsas som en textur # User Interface › Accent Color diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f88726b3fc..49a0360455 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4114,6 +4114,8 @@ axis Both Her İkisi @msg the Color Picker is not open Renk Seçici açık değil @msg the screen color sampler is already open ekran rengi örnekleyici zaten açık @msg this system has no screen color sampler bu sistemin ekran rengi örnekleyicisi yok +@msg the screen color sampler did not answer ekran rengi örnekleyici yanıt vermedi +@msg the screen color sampler failed ekran rengi örnekleyici başarısız oldu @msg bad colour `{hex}` (#rrggbb) kötü renk `{hex}` (#rrggbb) @msg bad page tree kötü sayfa ağacı @msg bad shortcut kötü kısayol diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8038529844..62773b7e0b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4386,6 +4386,8 @@ axis Both Обидві @msg the Smooth slider is not open повзунок «Згладити» не відкрито @msg the screen color sampler is already open засіб вибору кольору з екрана вже відкритий @msg this system has no screen color sampler у цій системі немає засобу вибору кольору з екрана +@msg the screen color sampler did not answer засіб вибору кольору з екрана не відповів +@msg the screen color sampler failed у засобі вибору кольору з екрана сталася помилка @msg bad colour `{hex}` (#rrggbb) неприпустимий колір `{hex}` (#rrggbb) @msg bad page tree неприпустиме дерево сторінок @msg bad shortcut неприпустиме поєднання клавіш diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6178748b4f..f1cdfab086 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4552,6 +4552,8 @@ theme Appearance Giao diện @msg the Smooth slider is not open Thanh trượt Làm mượt chưa được mở @msg the screen color sampler is already open công cụ lấy mẫu màu trên màn hình đã được mở @msg this system has no screen color sampler hệ thống này không có công cụ lấy mẫu màu trên màn hình +@msg the screen color sampler did not answer công cụ lấy mẫu màu trên màn hình không phản hồi +@msg the screen color sampler failed công cụ lấy mẫu màu trên màn hình gặp lỗi @msg bad colour `{hex}` (#rrggbb) màu không hợp lệ `{hex}` (#rrggbb) @msg bad page tree cây trang bị lỗi @msg bad shortcut phím tắt không hợp lệ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 20ae4298b6..689b384ee5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4453,6 +4453,8 @@ imageTrace Outline 轮廓 @msg the Smooth slider is not open 平滑滑块未打开 @msg the screen color sampler is already open 屏幕取色器已打开 @msg this system has no screen color sampler 此系统没有屏幕取色器 +@msg the screen color sampler did not answer 屏幕取色器没有响应 +@msg the screen color sampler failed 屏幕取色器出错 @msg Corners edits the corners of a path 转角用于编辑路径的转角 @msg give corners of the path: anchors where its sides meet at an angle 请给出路径的转角:其各边以一定角度相交处的锚点 @msg select one path, or give its `id` 请选择一条路径,或给出其 `id` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ee57010551..5833e95e82 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2256,6 +2256,8 @@ file-filter Templates 範本 @msg the Smooth slider is not open 「平滑」滑桿未開啟 @msg the screen color sampler is already open 螢幕取色器已開啟 @msg this system has no screen color sampler 此系統沒有螢幕取色器 +@msg the screen color sampler did not answer 螢幕取色器沒有回應 +@msg the screen color sampler failed 螢幕取色器發生錯誤 @msg Corners edits the corners of a path 「轉角」用於編輯路徑的轉角 @msg give corners of the path: anchors where its sides meet at an angle 請提供路徑的轉角:其各邊以角度相交處的錨點 @msg select one path, or give its `id` 請選取一條路徑,或提供其 `id` diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index b661682a3c..b50edf1baf 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -784,7 +784,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.sampleScreenColor", "Sample Color from Screen", "", - "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; Escape cancels it and the colors stay as they were. ui.inspect reports `screenSampler: true` until it answers. An error without a sampler, while one is open, or with `picker` and no Color Picker open", + "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; Linux through the XDG desktop portal's PickColor; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; a cancel (Escape on macOS and in browsers) leaves the colors as they were. ui.inspect reports `screenSampler: true` until it answers; on Linux, a session without a portal that has PickColor, a portal that does not return from the call within 25 seconds (VectorCraft's own limit) or leaves the bus before it answers, and a portal that ends the request another way end the wait with a status message. An error without a sampler, while one is open, or with `picker` and no Color Picker open", ), ( "ui.newBrush", diff --git a/crates/ui-egui/src/screen_color.rs b/crates/ui-egui/src/screen_color.rs index 9b81405489..bf17c9544b 100644 --- a/crates/ui-egui/src/screen_color.rs +++ b/crates/ui-egui/src/screen_color.rs @@ -1,9 +1,10 @@ //! Sample Color from Screen (`ui.sampleScreenColor`): the system's color sampler picks a color -//! anywhere on the screen ([`crate::Services::screen_color`]: `NSColorSampler` on macOS, the -//! browser's EyeDropper where the browser has one). The command shows the sampler and answers at -//! once. The color arrives in a later frame ([`poll`]) and becomes the fill or the stroke through -//! `paint.setFill` / `paint.setStroke` (one undo step), or the open Color Picker's color. When the -//! person cancels the sampler (Escape), the colors stay as they were. +//! anywhere on the screen ([`crate::Services::screen_color`]: `NSColorSampler` on macOS, the XDG +//! desktop portal's `PickColor` on Linux, the browser's EyeDropper where the browser has one). The +//! command shows the sampler and answers at once. The color arrives in a later frame ([`poll`]) +//! and becomes the fill or the stroke through `paint.setFill` / `paint.setStroke` (one undo step), +//! or the open Color Picker's color. When the person cancels the sampler (Escape on macOS and in +//! browsers), the colors stay as they were. //! //! The sampler answers in sRGB (macOS clips a color outside sRGB to it). [`sampled`] converts that //! into the working RGB space, so when the working space is not sRGB, a color inside sRGB looks as diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 45395b8fa7..f04a60f931 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -138,9 +138,10 @@ pub struct Services { pub open_in_photocraft: Option, /// Show a folder picker; returns its path (desktop: Relink to Folder, Package). pub pick_folder: Option Option>>, - /// Show the system's color sampler, which picks a color anywhere on the screen (macOS, and - /// browsers with an EyeDropper): Sample Color from Screen ([`screen_color`]). Without it that - /// command answers with an error and the Color Picker's eyedropper button is gray. + /// Show the system's color sampler, which picks a color anywhere on the screen (macOS, Linux + /// through the desktop portal, and browsers with an EyeDropper): Sample Color from Screen + /// ([`screen_color`]). Without it that command answers with an error and the Color Picker's + /// eyedropper button is gray. pub screen_color: Option, /// Where Data Recovery keeps its copies when not in a folder (the web's browser storage; /// tests): installed into the session ([`vectorcraft_engine::cmd::recovery`]). diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 11ebf094db..cb5b7a36db 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -148,7 +148,7 @@ fill or stroke (fields as in `swatchOptions` plus `group`; a gradient or pattern The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", params: {stroke?, color?}}` opens it for the fill (or stroke) proxy; `ui.dialog.set {field: "hex", value: "00FF00"}` (or `color`, `channel`, `webOnly`, `swatches`) then `ui.dialog.confirm` applies the colour through `paint.setFill` / `paint.setStroke`. -Sample Color from Screen: `ui.sampleScreenColor {stroke?, picker?}` shows the system's color sampler (macOS, and browsers with an EyeDropper) and returns `null` at once. When the person at the computer clicks a color anywhere on the screen, it runs `paint.setFill` (or `paint.setStroke`) with that color as one undo step, or with `picker: true` sets the open Color Picker's `color`; Escape cancels it and the colors stay as they were. The sampled display sRGB color is converted into the working RGB space. `ui.inspect` reports `screenSampler: true` until the sampler answers. Without a sampler (Windows, Linux, other browsers) the call returns an error. To read a color from VectorCraft's own window without a person, call `ui.screenshot`. +Sample Color from Screen: `ui.sampleScreenColor {stroke?, picker?}` shows the system's color sampler (macOS; Linux through the XDG desktop portal's [PickColor](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Screenshot.html); browsers with an EyeDropper) and returns `null` at once. When the person at the computer clicks a color anywhere on the screen, it runs `paint.setFill` (or `paint.setStroke`) with that color as one undo step, or with `picker: true` sets the open Color Picker's `color`; a cancel (Escape on macOS and in browsers) leaves the colors as they were. The sampled display sRGB color is converted into the working RGB space. `ui.inspect` reports `screenSampler: true` until the sampler answers. On Linux, a session without a portal that has PickColor, a portal that does not return from the call within 25 seconds (VectorCraft's own limit) or leaves the bus before it answers, and a portal that ends the request in another way each put a message in the status bar and end the wait, so the sampler can be shown again. A portal that returns from the call and then neither answers nor leaves the bus keeps the sampler open. Without a sampler (Windows, FreeBSD, DragonFly BSD, NetBSD, OpenBSD, other browsers) the call returns an error. To read a color from VectorCraft's own window without a person, call `ui.screenshot`. Graphic styles: `ui.graphicStyleOptions {name}` opens the `graphicStyleOptions` dialog (field `name`) for a style and `ui.dialog.confirm` renames it with `graphicStyle.rename` (a name another style has returns an error and keeps the diff --git a/docs/mcp.md b/docs/mcp.md index b9c23ad2d1..e4074d4509 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -490,6 +490,19 @@ opens the picker through `run_command` with the remote backend. `ui.colorPanel { the panel menu's Show Recent Colors, which hides the Color panel's Recent Colors row, and `true` shows it again; applied colors still join the list while it is hidden. +In remote desktop mode, `invoke_menu` with `{"command":"ui.sampleScreenColor","params":{}}` shows the system's +screen color sampler and returns `null` before a color is picked: the system's sampler on macOS, the XDG desktop +portal's [PickColor](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Screenshot.html) +on Linux, and the EyeDropper API in browsers that have it. `inspect_ui` reports `screenSampler: true` while it +waits. The picked sRGB color is converted into the working RGB space and becomes the fill or the stroke of the +active proxy in one undo step; `stroke: true` picks the stroke, and `picker: true` sends the color to the open +Color Picker. A cancel leaves the colors as they were. On Linux, a session without a portal that has PickColor +shows "this system has no screen color sampler" in the status bar, a portal that does not return from the call +within 25 seconds (VectorCraft's own limit) or leaves the bus before it answers shows "the screen color sampler +did not answer", and a portal that ends the request in another way shows "the screen color sampler failed"; each +ends the wait, so the sampler can be shown again. Windows, the BSDs and headless sessions have no screen color +sampler. + | Tool | Arguments | Notes | |---|---|---| | `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc, enablement, and `schema` and `inputSchema` for a command with a parameter schema (the params doc and both schemas are left out with `brief`). | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9c58bcaa8c..d4933e5a2c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1320,7 +1320,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **RGB input to other commands in a CMYK document:** type colors (`text.create`, `text.setStyle`, `text.setRangeStyle`), a Shape Builder hex fill, `paint.editGradient` stops, freeform gradient points, gradient mesh points (the Mesh tool gives a new point the fill's color in its own model), the Symbol Stainer's color and the swatch commands (`swatch.new {colors}`, `swatch.newGroup`, `swatch.editGroup`, `swatch.edit`) separate RGB through the color settings as `paint.setFill` does: with Generic CMYK, `text.setStyle {fill: "#53aadf"}` gives C62 M19 Y0 K0. A color a swatch already has keeps its model, and a swatch color given with `mode` is converted into that mode only. - **Generic CMYK (SWOP-like):** each ink's 40% and 50% tints gain as [CGATS TR 001](https://registry.color.org/cmyk-registry/cgats_tr_001) gives for SWOP printing, and separations stay within SWOP's [300% total ink limit](https://en.wikipedia.org/wiki/Specifications_for_Web_Offset_Publications). The paper, solid and overprint colors, the black-generation curve and the black point are VectorCraft's own values: RGB black separates to 90% black under neutral cyan, magenta and yellow that fill the ink limit (C75 M68 Y67 K90). - **Swatches made from a shown color in a CMYK document:** the Color panel menu's Create New Swatch, an Alt-click on the Swatches panel's New Swatch button, the New Swatch dialog for a gradient, a Fill or Stroke proxy or the Gradient panel's thumbnail dropped on the Swatches panel (or Alt-dropped on a swatch of its kind), and the Gradient panel's Add to Swatches and Save to Swatches keep each color's model, as the Color Guide's and Color Themes' saved colors do: an RGB object's color gives an RGB swatch, and a gradient's RGB stops stay RGB. - - **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. Windows, Linux and other browsers have no sampler yet; the button is gray there. + - **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the XDG desktop portal's [PickColor](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Screenshot.html) on Linux, and the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. On Linux, a session without a portal that has PickColor, a portal that does not return from the call within 25 seconds (VectorCraft's own limit) or leaves the bus before it answers, and a portal that ends the request another way each show a status message and end the wait. Windows, the BSDs and other browsers have no sampler yet; the button is gray there. - **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/it/stroke_visibility.rs`). - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. - The Gradient panel's Delete Stop sits at the right end of the ramp, level with it, so a panel too short to show the stop fields still shows it; it deletes the selected stop (never below two), as dragging a stop down off the ramp does (#5403). From a1a23c46e49e31e7f55db8175a6f4071b303fa52 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:05:53 -0800 Subject: [PATCH 24/79] Warp: the Warp effects bend an image's pixels with its frame A Warp effect on an image, or on a group that holds one, bent the image's frame but drew the pixels unbent, cut by the bent frame. The image now bends with the frame as a raster mesh warp, as an envelope bends it: after the last effect in the list, the image is cut into 10 x 10 cells of two triangles, each a clipped piece of the image under its own affine map, through every Warp it took and the Transform moves between them. Effects after a Warp reshape only the frame, and a placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. The image itself is unchanged, so editing or removing the warp redraws it. The images stay unbent when their pieces would make the art pass 2 million objects, and an image stays unbent when its clipping frame keeps its shape because the warp would pass the effects' anchor budget. - SVG export writes the image once and draws the pieces with when a group holds pieces of one image clipped by triangles. PDF, EPS, .ai and PNG exports write the bent pieces. - Envelopes: a grayscale image's fill color now colorizes the pieces an envelope cuts. Before, the pieces drew the gray image. - Geometry effects: an image under any geometry effect keeps its fill color and its smoothing setting. Before, it drew the gray image, smoothed. Docs: Warp params text, docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/live.rs | 2 +- crates/doc/src/live/expand.rs | 47 ++- crates/effects/src/catalog.rs | 2 +- crates/effects/src/reshape.rs | 296 ++++++++++++++++++- crates/effects/src/tests.rs | 311 ++++++++++++++++++++ crates/effects/src/warp.rs | 18 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/envelope_distort.rs | 29 ++ crates/engine/src/tests/warp_images.rs | 293 ++++++++++++++++++ crates/svg/src/export/nodes.rs | 19 ++ docs/mcp.md | 10 + docs/target-app-parity.md | 1 + 12 files changed, 991 insertions(+), 38 deletions(-) create mode 100644 crates/engine/src/tests/warp_images.rs diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index cfba618bad..d1eb20ed21 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -60,7 +60,7 @@ pub use envelope::{ pub(crate) use expand::max_outset; pub use expand::{ Hooks, MAX_GENERATED, Outliner, PatternTiles, affine_near, expand_deep, expand_deep_hooks, expand_live, expand_live_hooks, expand_live_with, - expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, subtree_has_live, + expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, mesh_warp_cost, mesh_warp_image, subtree_has_live, }; #[cfg(test)] use mesh::grid_eval; diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 8675dcc011..7c80866ee7 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -178,29 +178,46 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option { a.is_finite().then_some(a) } -/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into -/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map -/// that takes the triangle where the warp takes its corners (with Preserve Shape Using -/// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing). -/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no -/// seams show. +/// Image `n` (`im`) pushed through the envelope's warp as a raster mesh warp ([`mesh_warp_image`] +/// with `w.cells` cells, Preserve Shape Using Transparency and Anti-Alias as the envelope's +/// options say). fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { let k = w.cells.max(1); - // Two triangles a cell, three objects each; an image an inner envelope already cut into - // pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image - // is left out. - if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) { + // An image an inner envelope already cut into pieces is cut again here, so the pieces count + // against [`MAX_GENERATED`]. Past it, the image is left out. + if !Generating::charge(mesh_warp_cost(k)) { let mut g = n.clone(); g.kind = NodeKind::Group { children: vec![], clip: false }; return g; } + mesh_warp_image(n, im, w.f, k, !w.options.anti_alias, w.options.preserve_shape == PreserveShape::Transparency) +} + +/// How many objects [`mesh_warp_image`] makes of one image with `cells` rows and columns: two +/// triangles a cell, three objects each. +pub fn mesh_warp_cost(cells: usize) -> usize { + let k = cells.max(1); + k.saturating_mul(k).saturating_mul(6) +} + +/// Image `n` (`im`) pushed through point map `f` as a raster mesh warp: its frame cut into +/// `cells`² cells of two triangles, each a clip group showing the image under the affine map +/// that takes the triangle where `f` takes its corners (with `masked`, the image masked by the +/// triangle instead; with `pixelated`, drawn without smoothing). Each piece reaches a little past +/// its edges (at most half a point) so neighbors overlap and no seams show. Each piece keeps the +/// color `n` colorizes a grayscale image with. The result is a group with `n`'s id, name, +/// transparency, opacity mask and appearance. +pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, cells: usize, pixelated: bool, masked: bool) -> Node { + let k = cells.max(1); + // A grayscale image's fill color colorizes each piece; the group of pieces takes none. + let colorize = n.extras().colorize.clone(); let (pw, ph) = (im.width as f64, im.height as f64); let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64); let mut pieces = Vec::with_capacity(2 * k * k); for j in 0..k { for i in 0..k { let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)]; - let d = s.map(|p| (w.f)(im.xf * p)); + let d = s.map(|p| f(im.xf * p)); for t in [[0, 1, 2], [0, 2, 3]] { let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v])); let Some(xf) = affine_between(st, dt) else { continue }; @@ -216,8 +233,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { .collect(); let shape = PathData::single(SubPath::polyline(&tri, true)); let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); - image.extras_mut().pixelated = !w.options.anti_alias; - if w.options.preserve_shape == PreserveShape::Transparency { + image.extras_mut().pixelated = pixelated; + image.extras_mut().colorize = colorize.clone(); + if masked { // Preserve Shape Using Transparency: the triangle is the piece's alpha (a // white opacity mask) instead of its clipping path. let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)); @@ -234,6 +252,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { } let mut g = n.clone(); g.kind = NodeKind::Group { children: pieces, clip: false }; + if let Some(e) = g.extras_if_any() { + e.colorize = None; + } g } diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index a827bea0e2..7cbe2b2395 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -112,7 +112,7 @@ fn lengths_of(id: &str) -> Lengths { } } -const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}"; +const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds, and images bend as 10 × 10 cells of two triangles unless the pieces would make the art pass 2,000,000 objects or the image's frame keeps its shape past the anchor budget), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}"; const DT: &[&str] = &["Effect", "Distort & Transform"]; diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index c629cc10ca..9d93f82a79 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -4,19 +4,22 @@ //! Geometry effects reach them through outlines: [`outline_art`] turns the object into plain paths //! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect //! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a -//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects -//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without +//! warp bends a line of type as a whole. An image is clipped by its reshaped frame, and a Warp +//! effect bends its pixels with the frame ([`cut_warped_images`]). Raster effects (shadows, glows, blur, +//! feather) stay on the result for the renderer and exporters. Type without //! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those //! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and //! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives //! them ([`image_rows`]). use std::collections::HashSet; +use std::rc::Rc; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use vectorcraft_doc::live::Hooks; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind}; -use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, ImageObject, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes}; use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params}; @@ -548,7 +551,14 @@ fn as_art_opts( let mut art = match &n.kind { NodeKind::Image(im) => { let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); - let image = Node::new(n.id, n.kind.clone()); + let mut image = Node::new(n.id, n.kind.clone()); + // Its pixels draw as the image's do: colorized by a grayscale image's fill color, and + // smoothed or not. + { + let mut e = image.extras_mut(); + e.colorize = n.extras().colorize.clone(); + e.pixelated = n.extras().pixelated; + } let clipped = || { let mut clip = Node::path(n.id, frame.clone(), Appearance::default()); if let NodeKind::Path { clipping, .. } = &mut clip.kind { @@ -632,9 +642,9 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> { /// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as /// its reference box and the art's seed (a random Transform moves the pieces together). Images and -/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry -/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The -/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path +/// symbol instances follow a Transform effect the same way ([`transform_placed`]), and images +/// follow a Warp effect as a raster mesh warp ([`cut_warped_images`]); other geometry effects leave them +/// as they are. The paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path /// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns /// that live-effect plug-ins painting their pieces name are looked up in `doc`, and a deferred one /// is listed by its index in `owner`, the effect list `fx` was taken from ([`reshape_leaves_apart`]). @@ -705,8 +715,9 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget let max = budget.at_least(used); // Read before the effects run: a path's own copies don't make it art of several objects. let copy_whole = copies_whole(art); + let mut warped = WarpedImages::new(); for e in fx { - let Some(bounds) = art.geometric_bounds() else { break }; + let Some(bounds) = warped.bounds(art) else { break }; let params = merged_params(&e.id, &e.params); // Where a live-effect plug-in sits, which it is listed by when it is deferred. let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e))); @@ -739,10 +750,21 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget } let mut groups = vec![]; if follows { - group_marks(art, &mut vec![], &HashSet::new(), &mut groups); + group_marks(art, &mut vec![], &HashSet::new(), None, &mut groups); + // The images earlier Warps bend are measured by their pieces only when a group's + // gradient needs the bounds. + if !groups.is_empty() + && let Some(images) = warped.when_cut(art) + { + groups.clear(); + group_marks(art, &mut vec![], &HashSet::new(), Some(images), &mut groups); + } } - // The ids of the paths that kept their shape (past the budget). + // The ids of the paths that kept their shape (past the budget), and their places in the + // order [`for_each_leaf`] visits the paths. let mut kept_ids = HashSet::new(); + let mut kept_leaves = HashSet::new(); + let mut leaf_at = 0usize; let transform = (e.id == "distort.transform").then(|| { let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); @@ -757,6 +779,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget continue; } for_each_leaf(art, &mut |leaf| { + let at = leaf_at; + leaf_at += 1; let Some((path, rule)) = leaf_geometry(leaf) else { return }; // What the other paths leave of the budget. let before = Budget::of(&path); @@ -789,6 +813,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else { kept_ids.insert(leaf.id); + kept_leaves.insert(at); return; }; used = used.minus(before).plus(Budget::of(&out)); @@ -804,6 +829,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget if boxes { rebase_leaves(art, &mut leaves.into_iter()); } + // Warp bends images too: each one's pixels follow the warp's map over the same box. The + // images are cut into pieces once, after the last effect ([`cut_warped_images`]). An image + // whose clipping frame kept its shape (past the budget) keeps its pixels as they are. + if let Some(style) = e.id.strip_prefix("warp.").and_then(crate::warp::WarpStyle::from_id) + && crate::warp::bends(&e.id, ¶ms) + { + let f: WarpMap = Rc::from(crate::warp::warp_map(bounds, style, ¶ms)); + warped.reserve(art); + mark_warped_images(art, &f, &kept_leaves, false, &mut 0, &mut warped); + } if let Some((m, copies, options, _)) = transform && options.objects { @@ -816,14 +851,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget // follow their bounds under Free Distort and Warp (they stay when no path changed them). // Each stays when a path under it that its bounds measure kept its shape (past the budget). let mut after = vec![]; - let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1; + let images = if groups.is_empty() { None } else { warped.when_cut(art) }; + let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, images, &mut after).1; if !groups.is_empty() { carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, ¶ms, bounds, from, to, seed)); } if follows && !held { - whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), art.geometric_bounds(), seed)); + whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), warped.bounds(art), seed)); } } + cut_warped_images(art, &warped); whole } @@ -1445,17 +1482,25 @@ type GroupMark = (Vec, Option, bool); /// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or /// compound path below it that those bounds measure is in `kept`, measured in one walk. Each group below `n` that -/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. -fn group_marks(n: &Node, at: &mut Vec, kept: &HashSet, out: &mut Vec) -> (Option, bool) { +/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. An image that `images` +/// lists is measured by the pieces it will be cut into ([`WarpedImages::image_bounds`]). +fn group_marks( + n: &Node, + at: &mut Vec, + kept: &HashSet, + images: Option<&WarpedImages>, + out: &mut Vec, +) -> (Option, bool) { match &n.kind { NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => (n.geometric_bounds(), kept.contains(&n.id)), + NodeKind::Image(_) => (images.map_or_else(|| n.geometric_bounds(), |w| w.image_bounds(n)), false), NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. } if !n.extras().type_outlines => { let layer = matches!(n.kind, NodeKind::Layer { .. }); let skip = children.len().saturating_sub(n.editing_children().len()); let (mut bounds, mut held) = (None, false); for (i, c) in children.iter().enumerate() { at.push(i); - let (b, h) = group_marks(c, at, kept, out); + let (b, h) = group_marks(c, at, kept, images, out); if is_member_group(c) && (c.appearance.has_positioned_gradient() || c.appearance.has_unplaced_gradient()) { out.push((at.clone(), b, h)); } @@ -1590,6 +1635,225 @@ pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, ma copy_placed(n, m, copies, &mut room); } +/// Rows and columns of the pieces a Warp effect cuts an image into (VectorCraft's own number). +pub(crate) const WARP_IMAGE_CELLS: usize = 10; + +/// A Warp effect's point map, shared by the images it bends. +pub(crate) type WarpMap = Rc Point>; + +/// One image a Warp effect bent: the image's own key, and each warp it took with the placement +/// the image had when it took it, in order. +type WarpedImage = (String, Vec<(WarpMap, Affine)>); + +/// The images Warp effects bend while [`reshape_leaves_apart`] runs the effects. Each such image's +/// key is replaced by a key of this table ([`WarpedImages::add`]), which a copy a later Transform +/// effect makes keeps. The images are cut into pieces once, after the last effect +/// ([`cut_warped_images`]): the effects after a Warp reshape the image's frame, not each piece, +/// and a later Warp or Transform moves the one set of pieces. +pub(crate) struct WarpedImages { + /// What this table's keys start with: no image key in the art starts with it + /// ([`WarpedImages::reserve`]). + prefix: String, + /// The prefix was checked against the art's image keys. + reserved: bool, + images: Vec, +} + +impl WarpedImages { + pub(crate) fn new() -> Self { + static TABLES: AtomicUsize = AtomicUsize::new(0); + Self { prefix: format!("\0warp{}:", TABLES.fetch_add(1, Ordering::Relaxed)), reserved: false, images: vec![] } + } + + /// Before the first image of `art` is marked: lengthen the prefix until no image key in `art` + /// starts with it, so no key from the document is read as one of this table's. + pub(crate) fn reserve(&mut self, art: &Node) { + if self.reserved { + return; + } + self.reserved = true; + let mut keys = vec![]; + art.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + keys.push(im.key.clone()); + } + }); + while keys.iter().any(|k| k.starts_with(self.prefix.as_str())) { + self.prefix.push(':'); + } + } + + /// What this table's keys start with. + #[cfg(test)] + pub(crate) fn prefix(&self) -> &str { + &self.prefix + } + + /// The image that `key`, a key of this table, stands for. + fn get(&self, key: &str) -> Option<&WarpedImage> { + self.images.get(key.strip_prefix(self.prefix.as_str())?.parse::().ok()?) + } + + /// Record that the image with key `key`, placed by `xf`, takes warp `f`; returns the image's + /// new key. + fn add(&mut self, key: &str, xf: Affine, f: &WarpMap) -> String { + let (own, mut warps) = self.get(key).cloned().unwrap_or_else(|| (key.to_string(), vec![])); + warps.push((f.clone(), xf)); + self.images.push((own, warps)); + format!("{}{}", self.prefix, self.images.len() - 1) + } + + /// Does the art `n` have room, within `max` nodes, for the pieces of every image under it that + /// this table lists? + fn fits(&self, n: &Node, max: usize) -> bool { + let mut count = 0usize; + n.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind + && self.get(&im.key).is_some() + { + count += 1; + } + }); + vectorcraft_doc::live::mesh_warp_cost(WARP_IMAGE_CELLS).saturating_mul(count) <= max.saturating_sub(n.count()) + } + + /// The geometric bounds of `art` as it draws after the effects so far: each image this table + /// lists measured by the pieces the warps and moves it took would cut it into, when the art + /// has room for them ([`cut_warped_images`]). + pub(crate) fn bounds(&self, art: &Node) -> Option { + if self.images.is_empty() || !self.fits(art, crate::distort::MAX_COPY_OBJECTS) { + return art.geometric_bounds(); + } + // The pieces the images would be cut into now: each group of pieces is shown or hidden as + // its image is, and hidden objects are left out of a layer's bounds. + let mut probe = art.clone(); + restore_warped_images(&mut probe, self, true); + probe.geometric_bounds() + } + + /// The geometric bounds of image `n`: when this table lists it, measured by the pieces the + /// warps and moves it took would cut it into. + fn image_bounds(&self, n: &Node) -> Option { + let NodeKind::Image(im) = &n.kind else { return n.geometric_bounds() }; + if self.get(&im.key).is_none() { + return n.geometric_bounds(); + } + let mut probe = Node::group(n.id, vec![Arc::new(n.clone())]); + restore_warped_images(&mut probe, self, true); + probe.geometric_bounds() + } + + /// This table when the art `art` has room for the pieces of the images it lists, so that a + /// measure of the art takes them bent ([`WarpedImages::bounds`]); `None` otherwise. + fn when_cut(&self, art: &Node) -> Option<&Self> { + (!self.images.is_empty() && self.fits(art, crate::distort::MAX_COPY_OBJECTS)).then_some(self) + } +} + +/// Record in `warped` that each image under `n` takes warp `f`, walking the groups and layers +/// [`for_each_leaf`] walks and counting the paths it passes in `at`. `kept` holds the places of +/// the paths that kept their shape (past the budget), in the order [`for_each_leaf`] visits the +/// paths of the art: an image in a clip group whose clipping art holds one (`held`) does not take +/// the warp. +pub(crate) fn mark_warped_images(n: &mut Node, f: &WarpMap, kept: &HashSet, held: bool, at: &mut usize, warped: &mut WarpedImages) { + let (NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. }) = &mut n.kind else { return }; + let mut held = held; + // A clip group's first member is its clipping art: when a path in it kept its shape, it and + // the members it clips keep theirs. + if *clip && let Some(first) = children.first() { + let start = *at; + held |= (start..start + leaf_count(first)).any(|p| kept.contains(&p)); + } + for c in children.iter_mut() { + match &c.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => *at += 1, + NodeKind::Group { .. } | NodeKind::Layer { .. } => mark_warped_images(Arc::make_mut(c), f, kept, held, at, warped), + NodeKind::Image(_) if !held => { + if let NodeKind::Image(im) = &mut Arc::make_mut(c).kind { + im.key = warped.add(&im.key, im.xf, f); + } + } + _ => {} + } + } +} + +/// Give each image under `n` that `warped` lists its own key back, and cut it into the pieces of +/// a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`], each piece drawn as the image +/// is, smoothed or not) through the warps it took and the moves between and after them +/// ([`warped_map`]). The art keeps to [`crate::distort::MAX_COPY_OBJECTS`] nodes: when the pieces +/// would pass it, no image is cut. An image whose placement has no inverse is not cut either. +fn cut_warped_images(n: &mut Node, warped: &WarpedImages) { + cut_warped_images_within(n, warped, crate::distort::MAX_COPY_OBJECTS); +} + +/// [`cut_warped_images`] with the art keeping to `max` nodes. +pub(crate) fn cut_warped_images_within(n: &mut Node, warped: &WarpedImages, max: usize) { + if warped.images.is_empty() { + return; + } + let cut = warped.fits(n, max); + restore_warped_images(n, warped, cut); +} + +/// The walk of [`cut_warped_images`]: each listed image gets its key back, and with `cut` is cut +/// into pieces. +fn restore_warped_images(n: &mut Node, warped: &WarpedImages, cut: bool) { + let Some(children) = n.children_mut() else { return }; + for c in children.iter_mut() { + let NodeKind::Image(im) = &c.kind else { + if c.children().is_some() { + restore_warped_images(Arc::make_mut(c), warped, cut); + } + continue; + }; + let Some((key, warps)) = warped.get(&im.key) else { continue }; + let mut image = (**c).clone(); + let NodeKind::Image(own) = &mut image.kind else { continue }; + own.key = key.clone(); + // The pieces start from the image as it was placed when it took its first warp. + let first = warps.first().map(|(_, at)| ImageObject { xf: *at, ..(**own).clone() }); + let map = warped_map(warps, own.xf); + *c = Arc::new(match (first, map) { + (Some(first), Some(map)) if cut => { + let pixelated = image.extras().pixelated; + vectorcraft_doc::live::mesh_warp_image(&image, &first, &*map, WARP_IMAGE_CELLS, pixelated, false) + } + _ => image, + }); + } +} + +/// The map that takes a point of an image placed by the first of `warps`' placements to where the +/// warps and the moves between them take it, for an image now placed by `placed`: each warp in +/// turn, then the move from the placement the image had when it took that warp to the one it had +/// at the next, or to `placed` after the last. `None` when a placement has no inverse. +fn warped_map(warps: &[(WarpMap, Affine)], placed: Affine) -> Option Point>> { + let mut steps: Vec<(WarpMap, Affine)> = Vec::with_capacity(warps.len()); + for (i, (f, at)) in warps.iter().enumerate() { + let det = at.determinant(); + if !det.is_finite() || det == 0.0 { + return None; + } + let next = warps.get(i + 1).map_or(placed, |(_, at)| *at); + let m = next * at.inverse(); + if !m.is_finite() { + return None; + } + steps.push((f.clone(), m)); + } + Some(Box::new(move |p| steps.iter().fold(p, |q, (f, m)| *m * f(q)))) +} + +/// How many paths [`for_each_leaf`] visits in `n`. +fn leaf_count(n: &Node) -> usize { + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => 1, + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().map(|c| leaf_count(c)).sum(), + _ => 0, + } +} + /// An object that a Transform effect places through its transform rather than reshapes: an image /// (pixel space to document) or a symbol instance (the symbol's art to document). fn is_placed(n: &Node) -> bool { diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index adc518043e..e63b38a462 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -369,6 +369,317 @@ fn stacked_transform_copies_stay_within_the_image_budget() { assert_eq!(count(&evaluate_container(None, &g).unwrap()), 1001); } +/// A 40 × 40 px image placed at (0, 0) at one point a pixel, with id `id`. +fn warp_test_image(id: u64) -> std::sync::Arc { + use vectorcraft_doc::{ImageObject, NodeId}; + let im = ImageObject { key: "k".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + std::sync::Arc::new(Node::new(NodeId(id), NodeKind::Image(Box::new(im)))) +} + +/// How many images `art` holds, and the keys they have. +fn warp_test_images(art: &Node) -> (usize, std::collections::BTreeSet) { + let (mut n, mut keys) = (0, std::collections::BTreeSet::new()); + art.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + n += 1; + keys.insert(im.key.clone()); + } + }); + (n, keys) +} + +#[test] +fn warped_images_stay_within_the_object_budget() { + use std::rc::Rc; + use std::sync::Arc; + + use vectorcraft_doc::NodeId; + let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1)); + let cost = vectorcraft_doc::live::mesh_warp_cost(crate::reshape::WARP_IMAGE_CELLS); + let none = std::collections::HashSet::new(); + let k = || std::collections::BTreeSet::from(["k".to_string()]); + let warp = |art: &mut Node, warps: usize| { + let mut warped = crate::reshape::WarpedImages::new(); + for _ in 0..warps { + crate::reshape::mark_warped_images(art, &lift, &none, false, &mut 0, &mut warped); + } + warped + }; + // The art has room for one image's pieces only: neither image is cut, and each gets its own + // key back. + let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]); + let warped = warp(&mut art, 1); + let max = art.count() + cost; + crate::reshape::cut_warped_images_within(&mut art, &warped, max); + assert_eq!(warp_test_images(&art), (2, k())); + // With room for both, each is cut into 200 pieces once, however many warps it took. + let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]); + let warped = warp(&mut art, 3); + let max = art.count() + 2 * cost; + crate::reshape::cut_warped_images_within(&mut art, &warped, max); + assert_eq!(warp_test_images(&art), (400, k())); + // Images clipped by their frames: the second frame (the second path) kept its shape, so its + // image stays whole, and the first is cut. The third's frame is a group of two paths, the + // second of them kept, with an image after them, and its image sits in a group of its own: + // both stay whole too. + let frame = |id: u64| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Default::default())); + let clip_group = |id: u64, children: Vec>| Arc::new(Node::new(NodeId(id), NodeKind::Group { children, clip: true })); + let nested = clip_group( + 4, + vec![ + Arc::new(Node::group(NodeId(4), vec![frame(4), frame(4), warp_test_image(5)])), + Arc::new(Node::group(NodeId(4), vec![warp_test_image(4)])), + ], + ); + let mut art = + Node::group(NodeId(1), vec![clip_group(2, vec![frame(2), warp_test_image(2)]), clip_group(3, vec![frame(3), warp_test_image(3)]), nested]); + let (mut at, mut warped) = (0, crate::reshape::WarpedImages::new()); + crate::reshape::mark_warped_images(&mut art, &lift, &std::collections::HashSet::from([1, 3]), false, &mut at, &mut warped); + assert_eq!(at, 4); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(warp_test_images(&art), (203, k())); +} + +/// An image key from the document that looks like a key of the table is not read as one, and a +/// hidden image stays out of the bounds the later effects take, as it does before it is warped. +#[test] +fn warped_images_keep_document_keys_and_hidden_images_apart() { + use std::rc::Rc; + use std::sync::Arc; + + use vectorcraft_doc::NodeId; + let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1)); + let none = std::collections::HashSet::new(); + let mut warped = crate::reshape::WarpedImages::new(); + let lookalike = format!("{}0", warped.prefix()); + let mut b = (*warp_test_image(3)).clone(); + if let NodeKind::Image(im) = &mut b.kind { + im.key = lookalike.clone(); + } + let mut art = Node::group(NodeId(1), vec![warp_test_image(2), Arc::new(b)]); + warped.reserve(&art); + crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(warp_test_images(&art), (400, std::collections::BTreeSet::from(["k".to_string(), lookalike]))); + // A layer of a 10 × 10 square and a hidden 40 × 40 image: the warped image is not measured. + let square = Arc::new(Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default())); + let mut hidden = (*warp_test_image(5)).clone(); + hidden.visible = false; + let mut layer = Node::layer(NodeId(6), "Layer 1", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(children) = layer.children_mut() { + *children = vec![square, Arc::new(hidden)]; + } + let before = layer.geometric_bounds(); + let mut warped = crate::reshape::WarpedImages::new(); + warped.reserve(&layer); + crate::reshape::mark_warped_images(&mut layer, &lift, &none, false, &mut 0, &mut warped); + assert_eq!(warped.bounds(&layer), before); + // A shown image is measured by the pieces it is cut into. + let mut art = Node::group(NodeId(1), vec![warp_test_image(2)]); + let mut warped = crate::reshape::WarpedImages::new(); + warped.reserve(&art); + crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped); + let measured = warped.bounds(&art); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(measured, art.geometric_bounds()); + assert!(measured.is_some_and(|b| b.y0 < -3.9), "{measured:?}"); + // An image placed at a tiny scale (a placement with a tiny determinant) is cut too; one placed + // with no area is not. + for (scale, pieces) in [(1e-7, 200), (0.0, 1)] { + let mut image = (*warp_test_image(2)).clone(); + if let NodeKind::Image(im) = &mut image.kind { + im.xf = Affine::scale(scale); + } + let mut art = Node::group(NodeId(1), vec![Arc::new(image)]); + let mut warped = crate::reshape::WarpedImages::new(); + crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(warp_test_images(&art).0, pieces, "{scale}"); + } +} + +/// An image whose frame keeps its shape because the warp would pass the anchor budget keeps its +/// pixels as they are; within the budget, both bend. +#[test] +fn an_image_whose_frame_passes_the_budget_stays_whole() { + use std::sync::Arc; + + use vectorcraft_doc::NodeId; + let clipped = || { + let mut frame = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut frame.kind { + *clipping = true; + } + Node::new(NodeId(1), NodeKind::Group { children: vec![Arc::new(frame), warp_test_image(1)], clip: true }) + }; + let arc = fx("warp.arc", json!({"bend": 50, "version": 1})); + let k = || std::collections::BTreeSet::from(["k".to_string()]); + let mut art = clipped(); + crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget { anchors: 0, lines: 0 }, None); + assert_eq!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).map(|p| p.anchor_count()), Some(4)); + assert_eq!(warp_test_images(&art), (1, k())); + let mut art = clipped(); + crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget::EFFECTS, None); + assert!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).is_some_and(|p| p.anchor_count() > 4)); + assert_eq!(warp_test_images(&art), (200, k())); +} + +/// The first image under `n` (the first piece of a cut image). +fn first_image(n: &Node) -> Option { + let mut first = None; + n.walk(&mut |c| { + if first.is_none() + && let NodeKind::Image(im) = &c.kind + { + first = Some((**im).clone()); + } + }); + first +} + +#[test] +fn effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces() { + let image = |effects: Vec| { + let mut n = (*warp_test_image(1)).clone(); + n.appearance.effects = effects; + n + }; + // Each piece's clipping path, and the image's frame (the outer clip group's clipping path). + let clips = |art: &Node| { + let (mut pieces, mut frame) = (vec![], None); + art.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind + && let Some(path) = children.first().and_then(|p| p.path_data()) + { + if matches!(children.get(1).map(|i| &i.kind), Some(NodeKind::Image(_))) && path.anchor_count() == 3 { + pieces.push(path.anchor_count()); + } else if frame.is_none() { + frame = Some(path.anchor_count()); + } + } + }); + (pieces, frame) + }; + let arc = fx("warp.arc", json!({"bend": 50, "version": 1})); + for after in [ + fx("distort.roughen", json!({"size": 5, "detail": 10})), + fx("stylize.roundCorners", json!({"radius": 5})), + fx("path.offsetPath", json!({"offset": -2})), + ] { + let art = crate::reshape::reshape(None, &image(vec![arc.clone(), after.clone()]), None).unwrap(); + let (pieces, frame) = clips(&art); + assert_eq!(pieces.len(), 200, "{}", after.id); + assert!(frame.is_some_and(|n| n > 4), "{}: {frame:?}", after.id); + assert_eq!(warp_test_images(&art), (200, std::collections::BTreeSet::from(["k".to_string()])), "{}", after.id); + } + // Two warps cut the image once, through both maps; a Transform after them moves the pieces. + let flag = fx("warp.flag", json!({"bend": 30, "version": 1})); + let mv = fx("distort.transform", json!({"moveH": 100, "moveV": 0})); + let art = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone()]), None).unwrap(); + assert_eq!(warp_test_images(&art).0, 200); + let moved = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone(), mv]), None).unwrap(); + assert_eq!(warp_test_images(&moved).0, 200); + let box1 = Rect::new(0.0, 0.0, 40.0, 40.0); + let f1 = crate::warp::warp_map(box1, crate::warp::WarpStyle::Arc, &merged_params("warp.arc", &arc.params)); + // The flag bends the box the arc leaves, which the arc's frame measures. + let box2 = crate::reshape::reshape(None, &image(vec![arc.clone()]), None).unwrap().geometric_bounds().unwrap(); + let f2 = crate::warp::warp_map(box2, crate::warp::WarpStyle::Flag, &merged_params("warp.flag", &flag.params)); + // The first piece's corners: the pixels (0, 0), (4, 0) and (4, 4). + for (art, by) in [(&art, 0.0), (&moved, 100.0)] { + let piece = first_image(art).unwrap(); + for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] { + let want = f2(f1(p)) + vectorcraft_geom::Vec2::new(by, 0.0); + assert!((piece.xf * p).distance(want) < 1e-6, "{p:?}: {:?} {want:?}", piece.xf * p); + } + } +} + +/// A warp with no version (VectorCraft's earlier map) bends an image's pixels with that map, as it +/// bends the frame. +#[test] +fn an_images_pixels_take_the_map_their_warps_version_names() { + for version in [json!(null), json!(0), json!(1)] { + let mut params = json!({"bend": 50}); + if !version.is_null() { + params["version"] = version.clone(); + } + let mut n = (*warp_test_image(1)).clone(); + n.appearance.effects = vec![fx("warp.arc", params.clone())]; + let art = crate::reshape::reshape(None, &n, None).unwrap(); + let f = crate::warp::warp_map(Rect::new(0.0, 0.0, 40.0, 40.0), crate::warp::WarpStyle::Arc, &merged_params("warp.arc", ¶ms)); + let piece = first_image(&art).unwrap(); + for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] { + assert!((piece.xf * p).distance(f(p)) < 1e-6, "{version}: {p:?}"); + } + // The frame has an anchor where the map takes each corner. + let frame = art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).unwrap(); + for corner in [Point::new(0.0, 0.0), Point::new(40.0, 0.0), Point::new(40.0, 40.0), Point::new(0.0, 40.0)] { + let near = frame.subpaths.iter().flat_map(|s| &s.anchors).any(|a| a.p.distance(f(corner)) < 1e-6); + assert!(near, "{version}: {corner:?}"); + } + } +} + +/// Under a group's Warp effect, a nested group's own placed gradient follows the nested group's +/// bounds to where its image's pieces draw them. +#[test] +fn a_nested_groups_gradient_follows_its_warped_image() { + use std::sync::Arc; + + use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; + use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, NodeId}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let geom = GradientGeom { start: (5.0, 20.0).into(), end: (35.0, 20.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let mut inner = Node::group(NodeId(5), vec![warp_test_image(1)]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed))); + let square = Node::path(NodeId(2), shapes::rectangle(Rect::new(60.0, 0.0, 100.0, 40.0)), Appearance::default()); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(square)]); + g.appearance.effects.push(fx("warp.arc", json!({"bend": 50, "version": 1}))); + let art = crate::group::evaluate_container(None, &g).unwrap(); + assert_eq!(warp_test_images(&art).0, 200); + let (from, mut to) = (Rect::new(0.0, 0.0, 40.0, 40.0), None); + art.walk(&mut |c| { + if c.id == NodeId(5) && to.is_none() { + to = c.geometric_bounds(); + } + }); + let to = to.unwrap(); + assert!(to.y0 < from.y0 - 1.0, "the pieces draw the image bent: {to:?}"); + let follow = |p: Point| Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()); + let mut checked = 0; + art.walk(&mut |c| { + if c.id != NodeId(5) { + return; + } + for item in &c.appearance.items { + let Paint::Gradient(gp) = item.paint() else { continue }; + let got = gp.geom.unwrap(); + assert!(got.start.distance(follow(geom.start)) < 1e-6 && got.end.distance(follow(geom.end)) < 1e-6, "{got:?} in {to:?}"); + checked += 1; + } + }); + assert!(checked > 0); +} + +/// An image under a geometry effect other than a Warp draws as the image does: colorized by +/// a grayscale image's fill color, and smoothed or not. +#[test] +fn an_image_under_a_geometry_effect_keeps_its_color_and_smoothing() { + let mut n = (*warp_test_image(1)).clone(); + n.appearance.effects = vec![fx("distort.roughen", json!({"size": 5, "detail": 10}))]; + n.extras_mut().colorize = Some(Box::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)))); + n.extras_mut().pixelated = true; + let art = crate::reshape::reshape(None, &n, None).unwrap(); + let mut seen = vec![]; + art.walk(&mut |c| { + if matches!(c.kind, NodeKind::Image(_)) { + seen.push((c.extras().colorize.clone(), c.extras().pixelated)); + } + }); + assert_eq!(seen, vec![(n.extras().colorize.clone(), true)]); +} + /// What `effects` make of `path` within `budget`. fn within(effects: &[Effect], path: &PathData, budget: Budget) -> PathData { apply_geometry_within(effects, path, path.bounds().unwrap(), &GeomContext::default(), budget) diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index f607abacdf..4b117d4142 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -20,25 +20,30 @@ pub fn map_version(p: &Value) -> u32 { /// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]). pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData { + let piece = b.width().hypot(b.height()) / 24.0; + map_nonlinear_within(path, piece, max, warp_map(b, style, p)) +} + +/// The point map a warp of `style` with params `p` applies to box `b`: what [`warp`] moves each +/// point of a path to. +pub(crate) fn warp_map(b: Rect, style: WarpStyle, p: &Value) -> Box Point> { let bend = num(p, "bend", 50.0).clamp(-100.0, 100.0) / 100.0; let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0; let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0; let vertical = text(p, "orientation", "horizontal").eq_ignore_ascii_case("vertical"); - let piece = b.width().hypot(b.height()) / 24.0; if uses_net(map_version(p)) { let net = WarpNet::new(style, bend, dh, dv, b.width(), b.height(), vertical); let (w, h) = (b.width(), b.height()); // A box with no width or no height takes the middle of the patch along that side. - let f = |q: Point| { + return Box::new(move |q: Point| { let (u, v) = (if w > 0.0 { (q.x - b.x0) / w } else { 0.5 }, if h > 0.0 { (q.y - b.y0) / h } else { 0.5 }); b.origin() + net.at(u, v).to_vec2() - }; - return map_nonlinear_within(path, piece, max, f); + }); } let c = b.center(); let hw = (b.width() / 2.0).max(1e-9); let hh = (b.height() / 2.0).max(1e-9); - let f = |q: Point| { + Box::new(move |q: Point| { let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh); let (x2, y2) = if vertical { let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x); @@ -47,8 +52,7 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) - warp_point(style, bend, dh, dv, hh / hw, x, y) }; Point::new(c.x + x2 * hw, c.y + y2 * hh) - }; - map_nonlinear_within(path, piece, max, f) + }) } /// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index abf65d834a..de10dc4e17 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -205,6 +205,7 @@ mod typescale; mod typeundo; mod units; mod variables; +mod warp_images; mod webexport; mod widthpoints; mod widthprofiles; diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs index 2dbef41238..435d918552 100644 --- a/crates/engine/src/tests/envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -154,6 +154,35 @@ fn images_bend_as_a_raster_mesh_warp() { assert_eq!(svg.matches(" 200 && mid[1] < 60 && mid[2] < 60, "the middle rose, red: {mid:?}"); + let inside = img.pixel(120, 120); + assert!(inside[0] > 200 && inside[1] < 60 && inside[2] < 60, "red inside: {inside:?}"); +} + /// A selected embedded 2×2 image, 80×80 pt at (100, 100): a red column left of a blue one. fn two_color_image(s: &mut Session) -> NodeId { let img = image::RgbaImage::from_fn(2, 2, |x, _| if x == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) }); diff --git a/crates/engine/src/tests/warp_images.rs b/crates/engine/src/tests/warp_images.rs new file mode 100644 index 0000000000..47175fc866 --- /dev/null +++ b/crates/engine/src/tests/warp_images.rs @@ -0,0 +1,293 @@ +//! Effect › Warp on placed images: the pixels bend with the frame, in the canvas and in exports. + +use serde_json::{Value, json}; +use vectorcraft_doc::live::{WarpNet, WarpStyle, uses_net, warp_version}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::{Affine, Point, Rect}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +/// The box of the image [`quarters`] places. +const FRAME: Rect = Rect::new(100.0, 100.0, 180.0, 180.0); + +/// The color of the quarter of the image [`quarters`] places that holds (u, v) of its box: red top +/// left, green top right, blue bottom left, yellow bottom right. +fn quarter(u: f64, v: f64) -> [u8; 3] { + match (u < 0.5, v < 0.5) { + (true, true) => [255, 0, 0], + (false, true) => [0, 255, 0], + (true, false) => [0, 0, 255], + (false, false) => [255, 255, 0], + } +} + +/// A selected embedded 40×40 px image filling [`FRAME`], each quarter one color ([`quarter`]). +fn quarters(s: &mut Session) -> NodeId { + let img = image::RgbaImage::from_fn(40, 40, |x, y| { + let [r, g, b] = quarter(x as f64 / 40.0, y as f64 / 40.0); + image::Rgba([r, g, b, 255]) + }); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.edit("Place", |d, sel| { + d.images.insert("quarters".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0); + let im = vectorcraft_doc::ImageObject { + key: "quarters".into(), + width: 40, + height: 40, + xf, + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap() +} + +/// Where an Arc warp bending `bend` % takes the point at (u, v) of [`FRAME`]. +fn arced(bend: f64, u: f64, v: f64) -> Point { + let net = WarpNet::new(WarpStyle::Arc, bend / 100.0, 0.0, 0.0, FRAME.width(), FRAME.height(), false); + FRAME.origin() + net.at(u, v).to_vec2() +} + +/// Points of the image's box: near each quarter's middle and on both sides of the line between +/// the top and bottom quarters, where a warp that moves the pixels up or down changes the color. +const SAMPLES: [(f64, f64); 10] = + [(0.2, 0.2), (0.8, 0.2), (0.2, 0.8), (0.8, 0.8), (0.4, 0.4), (0.6, 0.4), (0.4, 0.6), (0.6, 0.6), (0.3, 0.62), (0.7, 0.62)]; + +/// The document drawn over (0, 0)–(400, 400) at one pixel a point, on white. +fn drawn(s: &Session) -> vectorcraft_render::Rendered { + vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 400.0, 400.0), 1.0, true) +} + +/// The sample points whose color in `img` at `at(u, v)` is not the image's color at (u, v). +fn wrong(img: &vectorcraft_render::Rendered, at: &dyn Fn(f64, f64) -> Point) -> Vec<(f64, f64, [u8; 4])> { + SAMPLES + .iter() + .filter_map(|&(u, v)| { + let p = at(u, v); + let px = img.pixel(p.x.floor() as u32, p.y.floor() as u32); + let want = quarter(u, v); + let near = (0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40); + (!near).then_some((u, v, px)) + }) + .collect() +} + +/// `quarters` with Effect › Warp › Arc at 50 %: the warp the effect holds, checked to be the +/// Bézier patch [`arced`] follows. +fn arc_image(s: &mut Session) -> NodeId { + let id = quarters(s); + s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let e = &n.appearance.effects[0]; + assert!(warp_version(&e.params["version"]).is_some_and(uses_net), "{}", e.params); + id +} + +fn decoded(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +#[test] +fn an_images_pixels_bend_with_effect_warp() { + let mut s = session(); + let id = arc_image(&mut s); + // The arc lifts the middle of the image: the line between the quarters rises there. + assert!(arced(50.0, 0.5, 0.6).y < FRAME.y0 + 0.5 * FRAME.height(), "{:?}", arced(50.0, 0.5, 0.6)); + let bent = drawn(&s); + assert_eq!(wrong(&bent, &|u, v| arced(50.0, u, v)), vec![], "each pixel is drawn where the warp takes it"); + // Drawn as pieces of the one image, each placed by the warp. + let d = &s.doc().unwrap().doc; + let art = vectorcraft_render::effects::reshape(Some(d), d.node(id).unwrap(), None).unwrap(); + let mut pieces = 0; + art.walk(&mut |c| pieces += matches!(c.kind, NodeKind::Image(_)) as usize); + assert!(pieces >= 100, "{pieces} pieces"); + // Removing the warp draws the image as it was, in one undo step. + let steps = s.doc().unwrap().history.undo.len(); + s.execute("effect.remove", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let plain = |u: f64, v: f64| Point::new(FRAME.x0 + u * FRAME.width(), FRAME.y0 + v * FRAME.height()); + assert_eq!(wrong(&drawn(&s), &plain), vec![]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![], "undo brings the bend back"); +} + +/// `out`, an export of [`arc_image`]'s document named `name`, opened again: it draws the image +/// bent. +fn opens_bent(name: &str, out: &Value) { + let mut back = session(); + back.execute("document.open", &json!({"name": name, "dataBase64": out["dataBase64"]})).unwrap(); + assert_eq!(wrong(&drawn(&back), &|u, v| arced(50.0, u, v)), vec![], "{name}"); +} + +#[test] +fn a_warped_image_keeps_its_bend_in_svg() { + let mut s = session(); + arc_image(&mut s); + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let text = String::from_utf8(decoded(&svg)).unwrap(); + assert_eq!(text.matches("= 100, "{images} pieces"); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]); + s.execute("edit.undo", &json!({})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + assert!(matches!(n.kind, NodeKind::Image(_)) && n.appearance.effects.len() == 1); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]); +} + +/// A selected embedded grayscale 40×40 px image filling [`FRAME`], black above white, colorized +/// red by its fill color: red above, nothing below. +fn red_on_clear(s: &mut Session) -> NodeId { + let img = image::GrayImage::from_fn(40, 40, |_, y| image::Luma([if y < 20 { 0 } else { 255 }])); + let mut png = vec![]; + image::DynamicImage::ImageLuma8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let id = s + .edit("Place", |d, sel| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0); + let im = vectorcraft_doc::ImageObject { + key: "gray".into(), + width: 40, + height: 40, + xf, + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id.0]})).unwrap(); + id +} + +#[test] +fn a_colorized_image_keeps_its_color_when_warped() { + let mut s = session(); + red_on_clear(&mut s); + s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap(); + let img = drawn(&s); + let (red, white) = ([255u8, 0, 0], [255u8, 255, 255]); + for (u, v, want) in [(0.5, 0.35, red), (0.2, 0.2, red), (0.5, 0.65, white), (0.8, 0.8, white)] { + let p = arced(50.0, u, v); + let px = img.pixel(p.x.floor() as u32, p.y.floor() as u32); + assert!((0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40), "({u}, {v}) at {p:?}: {px:?}"); + } +} + +#[test] +fn a_warp_on_a_group_bends_the_image_in_it() { + let mut s = session(); + let im = quarters(&mut s); + // A group of the image and a line below it as wide as the image: the group's box is the image's. + let line = s.execute("shape.line", &json!({"x1": FRAME.x0, "y1": FRAME.y1, "x2": FRAME.x1, "y2": FRAME.y1})).unwrap(); + let line = NodeId(line["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [im.0, line.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap(); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]); +} diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 3ab4003d7d..c5a98aebf1 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -121,6 +121,21 @@ impl Reuse { } } +/// Are `children` the pieces of a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`]): +/// more than one, each a clip group of a triangle and one and the same image? +fn image_pieces(children: &[std::sync::Arc]) -> bool { + let mut first: Option<(&str, u32, u32)> = None; + children.len() > 1 + && children.iter().all(|c| { + let NodeKind::Group { children, clip: true } = &c.kind else { return false }; + let [clip, im] = children.as_slice() else { return false }; + let (NodeKind::Path { path, .. }, NodeKind::Image(im)) = (&clip.kind, &im.kind) else { return false }; + let triangle = path.subpaths.len() == 1 && path.anchor_count() == 3; + let image = (im.key.as_str(), im.width, im.height); + triangle && *first.get_or_insert(image) == image + }) +} + /// Art about to be written moved by `m.inverse()` (a symbol instance's): map what the canvas /// leaves on the page by `m` first, so it stays there. That is every pattern paint's placement /// and the art of unlinked opacity masks. @@ -678,6 +693,10 @@ impl Writer<'_> { match &n.kind { NodeKind::Layer { template: true, .. } => {} NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + // The pieces a Warp effect cuts an image into share its pixels, as an envelope's do. + if image_pieces(children) { + self.share_images(n); + } let id = self.id_attr(n); let a = self.attrs(&self.group_props(n)); self.line(&format!("")); diff --git a/docs/mcp.md b/docs/mcp.md index 58a8006d03..9db2c0e1ff 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3332,6 +3332,16 @@ that shape, and the effects before it are left out. Its Contents row stands for vector exports write those items as paths, and `effect.expandAppearance` turns them into objects in a group with the image. Use Preview Bounds measures an image by its frame. +A Warp effect (`warp.arc` … `warp.twist`) on an image, or on a group that holds one, bends the image's pixels with its +frame: after the last effect in the list, the image is cut into 10 × 10 cells of two triangles, each a clipped piece of +the image under its own affine map. The cell count is VectorCraft's own. Effects after the Warp reshape only the frame, +a second Warp bends the same pieces, and a Transform effect moves them. A placed gradient on a group that holds the +image follows the group's bounds as the pieces draw them. The pieces keep the +image's fill color when it colorizes a grayscale image, and so do the pieces an envelope cuts. Under any geometry +effect, an image keeps that color and is drawn with or without smoothing as the image is. SVG writes the image once and +draws the pieces with ``; PDF, EPS, `.ai` and PNG write the bent pieces. Removing the warp shows the image as it +was. The images stay unbent when their pieces would make the art pass 2 million objects, and an image stays unbent when +its clipping frame keeps its shape because the warp would pass the effects' anchor budget. ```json {"name":"run_command","arguments":{"command":"appearance.addFill","params":{"ids":[7]}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b7c8e51357..98f852cc6f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -591,6 +591,7 @@ since 2026-10-01. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged. +- **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. From 4e03d655c01a7d18065933387d28301da4f69e9f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:20:37 -0800 Subject: [PATCH 25/79] Select: test Not Aligned to Pixel Grid with images, symbols, type and live objects Adds tests for Select > Object > Not Aligned to Pixel Grid with embedded images, symbol instances, point, area and path type, gradient meshes, placed documents, guides and live shapes, with paths on template layers, hidden and locked sublayers and locked artboards, and with the paths in clip groups, blends, envelopes, repeats and Intertwine groups. A compound path is selected by its own property, whatever the property of its paths. The command's params text and docs/mcp.md now say that it looks into clip groups, blends, envelopes, repeats and Intertwine groups. --- crates/engine/src/cmd/select.rs | 2 +- crates/engine/src/tests/pixelalign.rs | 199 ++++++++++++++++++++++++++ docs/mcp.md | 9 +- docs/target-app-parity.md | 2 +- 4 files changed, 207 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index aeeac58855..a2c7b3a8d4 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -184,7 +184,7 @@ pub fn specs() -> Vec { "Not Aligned to Pixel Grid", ["Select", "Object"], None, - "{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups but not into compound paths → {count}", + "{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups, clip groups, blends, envelopes, repeats and Intertwine groups but not into compound paths → {count}", has_doc, not_pixel_aligned ), diff --git a/crates/engine/src/tests/pixelalign.rs b/crates/engine/src/tests/pixelalign.rs index adb8bae15d..06057d1fc0 100644 --- a/crates/engine/src/tests/pixelalign.rs +++ b/crates/engine/src/tests/pixelalign.rs @@ -959,3 +959,202 @@ fn not_aligned_to_pixel_grid_leaves_out_what_cant_be_selected() { assert_eq!(s.execute("select.object.notAlignedToPixelGrid", &json!({})).unwrap()["count"], 1); assert_eq!(selected_ids(&s), vec![inside]); } + +/// Run Not Aligned to Pixel Grid with `before` selected, check that it makes no undo step and that +/// its count is the number of objects it selected → the selected ids, sorted. +fn not_aligned(s: &mut Session, before: &[u64]) -> Vec { + s.execute("select.set", &json!({ "ids": before })).unwrap(); + let steps = undo_len(s); + let r = s.execute("select.object.notAlignedToPixelGrid", &json!({})).unwrap(); + let ids = selected_ids(s); + assert_eq!(r["count"], json!(ids.len()), "{r}"); + assert_eq!(undo_len(s), steps); + ids +} + +fn sorted(mut ids: Vec) -> Vec { + ids.sort_unstable(); + ids +} + +/// What Select All selects, sorted. +fn all_ids(s: &mut Session) -> Vec { + s.execute("select.all", &json!({})).unwrap(); + selected_ids(s) +} + +fn kind(s: &Session, id: u64) -> NodeKind { + s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind.clone() +} + +/// Images, symbol instances, point type, area type and type on a path cannot take the property, so +/// Not Aligned to Pixel Grid leaves them out though Select All selects them; the path a symbol was +/// made from is the symbol's art, not an object of the document. +#[test] +fn not_aligned_to_pixel_grid_leaves_out_images_symbol_instances_and_type() { + let mut s = new_doc(); + let p = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 1.0); + let png = crate::tests_links::png(4, 4, [255, 0, 0]); + s.execute("file.place", &json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap(); + let image = s.doc().unwrap().selection.objects[0].0; + let art = path(&mut s, "M60.5 60 L80 60 L80 80 L60.5 80 Z", 1.0); + let made = s.execute("symbol.new", &json!({"name": "Mark", "ids": [art]})).unwrap()["id"].as_u64().unwrap(); + let placed = s.execute("symbol.place", &json!({"name": "Mark", "x": 200.3, "y": 200.3})).unwrap()["id"].as_u64().unwrap(); + let point = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "point"})).unwrap()["id"].as_u64().unwrap(); + let frame = json!({"width": 80.5, "height": 40.5}); + let area = s.execute("text.create", &json!({"x": 100, "y": 150, "text": "area", "area": frame})).unwrap()["id"].as_u64().unwrap(); + let on_path = path(&mut s, "M10.5 300 C100 250 200 350 300.5 300", 1.0); + let on_path = s.execute("text.createInPath", &json!({"path": on_path, "mode": "onPath", "text": "on a path"})).unwrap()["id"].as_u64().unwrap(); + let in_path = path(&mut s, "M250.5 20.5 L350 20.5 L350 90 L250.5 90 Z", 1.0); + let in_path = s.execute("text.createInPath", &json!({"path": in_path, "mode": "area", "text": "in a path"})).unwrap()["id"].as_u64().unwrap(); + assert!(matches!(kind(&s, image), NodeKind::Image(_))); + for id in [made, placed] { + assert!(matches!(kind(&s, id), NodeKind::SymbolInstance { .. }), "{id}"); + } + for id in [point, area, on_path, in_path] { + assert!(matches!(kind(&s, id), NodeKind::Text(_)), "{id}"); + } + assert!(s.doc().unwrap().doc.node(NodeId(art)).is_none()); + assert_eq!(all_ids(&mut s), sorted(vec![p, image, made, placed, point, area, on_path, in_path])); + assert_eq!(not_aligned(&mut s, &[image, made, point]), vec![p]); + // With the path aligned, nothing is left to select. + s.execute("object.setPixelAligned", &json!({"ids": [p]})).unwrap(); + assert_eq!(not_aligned(&mut s, &[placed]), Vec::::new()); +} + +/// Paths on a template layer, in a hidden or locked sublayer or on a locked artboard are left out, +/// as Select All leaves them out; paths in an unlocked visible sublayer are selected. +#[test] +fn not_aligned_to_pixel_grid_leaves_out_template_layers_locked_sublayers_and_locked_artboards() { + let mut s = new_doc(); + s.execute("artboard.new", &json!({"x": 500, "y": 0, "width": 100, "height": 100})).unwrap(); + let main = s.doc().unwrap().doc.layers[0].id.0; + let free = path(&mut s, "M10 10 L20 10 L20 20 L10 20 Z", 1.0); + let on_locked_board = path(&mut s, "M510 10 L520 10 L520 20 L510 20 Z", 1.0); + let template = s.execute("layer.new", &json!({"name": "Trace"})).unwrap()["id"].as_u64().unwrap(); + let traced = path(&mut s, "M30 10 L40 10 L40 20 L30 20 Z", 1.0); + s.execute("layer.setProps", &json!({"id": template, "template": true})).unwrap(); + let sub = |s: &mut Session, name: &str, d: &str| { + let l = s.execute("layer.newSublayer", &json!({"parent": main, "name": name})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.setCurrent", &json!({"id": l})).unwrap(); + (l, path(s, d, 1.0)) + }; + let (_, in_sublayer) = sub(&mut s, "Open", "M50 10 L60 10 L60 20 L50 20 Z"); + let (hidden, in_hidden) = sub(&mut s, "Hidden", "M70 10 L80 10 L80 20 L70 20 Z"); + let (locked, in_locked) = sub(&mut s, "Locked", "M90 10 L100 10 L100 20 L90 20 Z"); + s.execute("layer.setProps", &json!({"id": hidden, "visible": false})).unwrap(); + s.execute("layer.setProps", &json!({"id": locked, "locked": true})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + let all = all_ids(&mut s); + for id in [traced, in_hidden, in_locked, on_locked_board] { + assert!(!all.contains(&id), "{id}"); + } + assert_eq!(not_aligned(&mut s, &[free]), sorted(vec![free, in_sublayer])); + // Unlocked, the artboard's path is selected again. + s.execute("artboard.setProps", &json!({"index": 1, "locked": false})).unwrap(); + assert_eq!(not_aligned(&mut s, &[free]), sorted(vec![free, in_sublayer, on_locked_board])); +} + +/// A clipping path and the paths it clips are selected, not their clip group; a path with a drop +/// shadow or a Gaussian Blur is selected as the path it is, and its effects are not objects. +#[test] +fn not_aligned_to_pixel_grid_selects_clipping_paths_and_paths_with_effects() { + let mut s = new_doc(); + let clipped = path(&mut s, "M10.5 10 L60 10 L60 60 L10.5 60 Z", 1.0); + let clip = path(&mut s, "M20.5 20 L50 20 L50 50 L20.5 50 Z", 1.0); + s.execute("select.set", &json!({"ids": [clipped, clip]})).unwrap(); + let group = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + let shadowed = path(&mut s, "M100.5 10 L150 10 L150 60 L100.5 60 Z", 1.0); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [shadowed]})).unwrap(); + let blurred = path(&mut s, "M200.5 10 L250 10 L250 60 L200.5 60 Z", 1.0); + s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 3}, "ids": [blurred]})).unwrap(); + assert!(matches!(kind(&s, group), NodeKind::Group { .. })); + for id in [shadowed, blurred] { + assert!(!s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects.is_empty(), "{id}"); + } + let want = sorted(vec![clipped, clip, shadowed, blurred]); + assert_eq!(not_aligned(&mut s, &[group]), want); + // Object › Align to Pixel Grid takes each object selected; then nothing is left to select. + s.execute("object.setPixelAligned", &json!({"ids": want})).unwrap(); + assert_eq!(not_aligned(&mut s, &[group]), Vec::::new()); +} + +/// The filter looks into blends, envelopes, repeats and Intertwine groups and selects the paths +/// they are made of, not the objects that hold them; Align to Pixel Grid takes that selection, and +/// then nothing is left to select. +#[test] +fn not_aligned_to_pixel_grid_selects_the_paths_of_blends_envelopes_repeats_and_intertwines() { + let mut s = new_doc(); + let b1 = path(&mut s, "M10.5 10 L30 10 L30 30 L10.5 30 Z", 1.0); + let b2 = path(&mut s, "M80.5 10 L100 10 L100 30 L80.5 30 Z", 1.0); + s.execute("select.set", &json!({"ids": [b1, b2]})).unwrap(); + let blend = s.execute("object.blend.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + let warped = path(&mut s, "M10.5 100 L100 100 L100 150 L10.5 150 Z", 1.0); + let envelope = s.execute("object.envelope.makeWithWarp", &json!({"ids": [warped]})).unwrap()["id"].as_u64().unwrap(); + let tile = path(&mut s, "M200.5 10 L220 10 L220 30 L200.5 30 Z", 1.0); + let repeat = s.execute("object.repeat.grid", &json!({"ids": [tile], "rows": 2, "cols": 2})).unwrap()["id"].as_u64().unwrap(); + let i1 = path(&mut s, "M200.5 200 L260 200 L260 230 L200.5 230 Z", 1.0); + let i2 = path(&mut s, "M220.5 180 L240 180 L240 250 L220.5 250 Z", 1.0); + let intertwine = s.execute("object.intertwine.make", &json!({"ids": [i1, i2]})).unwrap()["id"].as_u64().unwrap(); + assert!(matches!(kind(&s, blend), NodeKind::Blend { .. })); + assert!(matches!(kind(&s, envelope), NodeKind::Envelope { .. })); + assert!(matches!(kind(&s, repeat), NodeKind::Repeat(_))); + assert!(matches!(kind(&s, intertwine), NodeKind::Intertwine { .. })); + let want = sorted(vec![b1, b2, warped, tile, i1, i2]); + assert_eq!(not_aligned(&mut s, &[blend, envelope]), want); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + for id in &want { + assert!(aligned(&s, *id), "{id}"); + } + assert_eq!(not_aligned(&mut s, &[repeat, intertwine]), Vec::::new()); +} + +/// Gradient meshes, placed documents and ruler guides are left out; a live shape is a path and is +/// selected. +#[test] +fn not_aligned_to_pixel_grid_leaves_out_meshes_placed_documents_and_guides_but_takes_live_shapes() { + let mut s = new_doc(); + let filled = path(&mut s, "M10.5 10 L60 10 L60 60 L10.5 60 Z", 1.0); + s.execute("paint.setFill", &json!({"color": "#3366cc"})).unwrap(); + let mesh = s.execute("object.mesh.create", &json!({"ids": [filled], "rows": 2, "cols": 2})).unwrap()["ids"][0].as_u64().unwrap(); + let dir = crate::tests_links::Folder::new("pixelalign-placed"); + let file = dir.file("placed.vectorcraft"); + let mut d = vectorcraft_doc::Document::new(50.0, 50.0); + let layer = d.layers[0].id; + let id = d.alloc_id(); + let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.5, 0.5, 40.0, 40.0)); + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(20, 20, 230)); + let look = vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0); + d.insert(Some(layer), 0, vectorcraft_doc::Node::path(id, rect, look)).unwrap(); + crate::tests_links::write(&file, &vectorcraft_format::save_file(&d)); + let placed = s.execute("file.place", &json!({"path": file, "at": [200, 200]})).unwrap()["ids"][0].as_u64().unwrap(); + let guide = path(&mut s, "M0 300.5 L400 300.5", 1.0); + s.execute("view.guides.make", &json!({})).unwrap(); + let live = s.execute("shape.rectangle", &json!({"x": 100.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + assert!(matches!(kind(&s, mesh), NodeKind::Mesh(_))); + assert!(matches!(kind(&s, placed), NodeKind::PlacedDocument(_))); + assert!(matches!(kind(&s, guide), NodeKind::Path { guide: true, .. })); + assert!(s.doc().unwrap().doc.node(NodeId(live)).unwrap().live_shape().is_some()); + let all = all_ids(&mut s); + assert!(all.contains(&mesh) && all.contains(&placed), "{all:?}"); + assert_eq!(not_aligned(&mut s, &[mesh, placed]), vec![live]); +} + +/// A compound path goes by its own property, whatever the property of the paths in it (a file can +/// hold paths with the property on inside a compound path): off, it is selected as one object and +/// the paths in it are not; on, it is not selected, though a path in it has the property off. +#[test] +fn not_aligned_to_pixel_grid_takes_a_compound_path_by_its_own_property() { + let mut s = new_doc(); + let on = path(&mut s, "M10.5 10 L30 10 L30 30 L10.5 30 Z", 1.0); + let off = path(&mut s, "M50.5 10 L70 10 L70 30 L50.5 30 Z", 1.0); + s.execute("select.set", &json!({"ids": [on, off]})).unwrap(); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(on)).unwrap().extras_mut().pixel_aligned = true; + assert!(aligned(&s, on) && !aligned(&s, off) && !aligned(&s, compound)); + let other = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 1.0); + assert_eq!(not_aligned(&mut s, &[other]), sorted(vec![compound, other])); + s.execute("object.setPixelAligned", &json!({"ids": [compound]})).unwrap(); + assert!(!aligned(&s, off)); + assert_eq!(not_aligned(&mut s, &[compound]), vec![other]); +} diff --git a/docs/mcp.md b/docs/mcp.md index 288e86ae3e..5693acd159 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2330,9 +2330,12 @@ everything in it, and `select.all`, `select.allOnArtboard` and `select.inverse` `brush.list`), whatever the brush is named; a brush of another type with "Bristle" in its name does not count. `select.object.notAlignedToPixelGrid` (Select › Object › Not Aligned to Pixel Grid) selects the paths and compound paths whose pixel-aligned property (`object.setPixelAligned`) is off and returns `{count}`. It goes by the property, -not by where the anchors are: a path on whole points with the property off is selected. Guide paths, type, images, -groups and other objects are not, nor are the paths inside a compound path, which takes the property as one object. -It looks into groups, replaces the selection, and with no such object clears it; it records no undo step. +not by where the anchors are: a path on whole points with the property off is selected. Guide paths, type (point, +area and on a path), images, symbol instances, gradient meshes, placed documents, groups and other objects are not, +nor are the paths inside a compound path, which takes the property as one object. A path with effects is selected +as the path it is. It looks into groups, clip groups, blends, envelopes, repeats and Intertwine groups and selects the +paths they hold, not the objects that hold them. It replaces the selection, and with no such object clears it; it +records no undo step. `select.nextAbove` and `select.nextBelow` select the nearest such object above or below the first selected object among the objects beside it in its group or layer, passing over hidden and locked objects and sublayers. When there is no such object, the selection stays as it is. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 351ade4468..4cc4b01cda 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -946,7 +946,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Agents read the key object: `select.key` returns it, and `document.inspect` and the `ui.pointer` and `pointer_gesture` replies report it as `key`. `select.key` with an id that isn't a selected top-level object returns an error and leaves the key unchanged. The Select menu's Same commands, its Object commands that select art, Next Object Above and Below, saved selections and the Magic Wand (#2655) select only objects that Select All selects and objects inside them: nothing hidden or locked, nothing in a hidden or locked group, layer or sublayer, nothing on a template layer, and in isolation mode nothing outside the isolated group or layer. In isolation mode Select All, All on Active Artboard and Inverse select nothing while the isolated group or layer, or a group or layer it is in, is hidden, locked or a template layer. Select ▸ Object ▸ Bristle Brush Strokes selects the objects whose topmost stroke has a bristle brush, whatever the brush is named, and leaves out objects whose brush is of another type, also when its name has "Bristle" in it. - Select ▸ Object ▸ Not Aligned to Pixel Grid (`select.object.notAlignedToPixelGrid`) selects the paths and compound paths whose pixel-aligned property is off, whatever their coordinates, and leaves out guides, type, images, groups and the paths inside a compound path ([help page](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185); `tests::pixelalign::not_aligned_to_pixel_grid_selects_paths_without_the_property`, `not_aligned_to_pixel_grid_leaves_out_what_cant_be_selected`). + Select ▸ Object ▸ Not Aligned to Pixel Grid (`select.object.notAlignedToPixelGrid`) selects the paths and compound paths whose pixel-aligned property is off, whatever their coordinates, and leaves out guides, type, images, groups and the paths inside a compound path ([help page](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185); `tests::pixelalign::not_aligned_to_pixel_grid_selects_paths_without_the_property`, `not_aligned_to_pixel_grid_leaves_out_what_cant_be_selected`). It also leaves out symbol instances, area type, type on a path, gradient meshes and placed documents, and the paths on template layers, in hidden or locked sublayers and on locked artboards; it selects the paths in clip groups, blends, envelopes, repeats and Intertwine groups and paths with effects (`not_aligned_to_pixel_grid_leaves_out_images_symbol_instances_and_type`, `not_aligned_to_pixel_grid_leaves_out_template_layers_locked_sublayers_and_locked_artboards`, `not_aligned_to_pixel_grid_selects_clipping_paths_and_paths_with_effects`, `not_aligned_to_pixel_grid_selects_the_paths_of_blends_envelopes_repeats_and_intertwines`, `not_aligned_to_pixel_grid_leaves_out_meshes_placed_documents_and_guides_but_takes_live_shapes`, `not_aligned_to_pixel_grid_takes_a_compound_path_by_its_own_property`). Align and Distribute Spacing keep a compound path or selected group that contains the key object in place, as they keep the key object itself. Align to Glyph Bounds (#4231): the Align panel menu's Align to Glyph Bounds submenu has Point Text and Area Text, each checked while on (`alignGlyphBoundsPoint`, `alignGlyphBoundsArea`, off by default). With one on, Align, Distribute Objects and Distribute Spacing measure that kind of type by the ink of its glyphs instead of its em box (point type) or frame (area type), so lowercase type aligned to a rectangle's top lines up the tops of its letters; the other kind, type on a path and type in a selected group are measured as before, and Use Preview Bounds adds the characters' strokes ([Align text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/align-text.html); `tests::glyphbounds`, `panels::align::tests::align_to_glyph_bounds_toggles_point_and_area_text`). The Character and Properties panels' controls for the same setting are not done. Anchor Align moves the selected points to their bounds or the artboard (#1180). Repeating it, or moving by at most 1e-9 pt on both axes as whole-object Align does, leaves the document, Undo, Redo and saved state unchanged; actual moves still take one undo step. `crates/engine/src/tests.rs` covers repeated alignment, rounding-only alignment, unchanged handles, saved state, command recording and Undo/Redo. From 98d6cedf2530ed31dca722052ac488fcdec9a0fd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:27:01 -0800 Subject: [PATCH 26/79] Tests: Direct Selection marquee and hover and Group Selection pass through raster effects Six tests cover the Direct Selection marquee (anchors and crossed segments), its pointer and anchor highlight, and the Group Selection click, repeated click, marquee (default and E for enclosed objects) and hover over a drop shadow: they reach the object under the shadow or nothing. The selection passes-through line in docs/target-app-parity.md names these gestures. --- crates/engine/src/tests/select_effects.rs | 157 ++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests/select_effects.rs b/crates/engine/src/tests/select_effects.rs index b30d0ef70e..d2160fba34 100644 --- a/crates/engine/src/tests/select_effects.rs +++ b/crates/engine/src/tests/select_effects.rs @@ -234,3 +234,160 @@ fn a_marquee_that_touches_only_a_drop_shadow_does_not_select_its_object() { // From above B, over B and the shadow: B only. assert_eq!(press(&mut s, "selection", (105.0, 5.0), (115.0, 30.0), Mods::default()), [b]); } + +/// The selection after a drag with `tool` from `from` to `to`. +fn marquee(s: &mut Session, tool: &str, from: (f64, f64), to: (f64, f64)) -> vectorcraft_doc::Selection { + press(s, tool, from, to, Mods::default()); + s.doc().unwrap().selection.clone() +} + +/// The point of anchor `(si, ai)` of path `id`. +fn anchor_point(s: &Session, id: NodeId, (si, ai): vectorcraft_doc::AnchorRef) -> Point { + let path = s.doc().unwrap().doc.node(id).and_then(|n| n.path_data()).unwrap(); + path.anchors().find(|(s, i, _)| (*s, *i) == (si, ai)).map(|(.., a)| a.p).unwrap() +} + +/// The anchors a marquee or a click picked on `id`, as points. +fn picked_points(s: &Session, id: NodeId) -> Vec { + let refs: Vec<_> = s.doc().unwrap().selection.partial(id).into_iter().flatten().copied().collect(); + refs.into_iter().map(|r| anchor_point(s, id, r)).collect() +} + +/// The anchors the tool marks as hovered. +fn anchor_marks(s: &mut Session) -> Vec { + s.overlays(ViewInfo::default()) + .into_iter() + .filter_map(|o| if let vectorcraft_tools::Overlay::Anchor { p, .. } = o { Some(p) } else { None }) + .collect() +} + +fn hover(s: &mut Session, x: f64, y: f64) -> Cursor { + let v = ViewInfo::default(); + s.pointer(&PointerEvent::new(PointerKind::Move, x, y), v).unwrap(); + s.cursor(Point::new(x, y), Mods::default(), v) +} + +#[test] +fn a_direct_selection_marquee_leaves_out_a_drop_shadow() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + let (a_before, b_before) = (bounds(&s, a), bounds(&s, b)); + assert_ne!(pixel(&s, 90, 50), WHITE); + run(&mut s, "select.none", json!({})); + // Over the shadow alone: no object, no anchor, no segment. + let sel = marquee(&mut s, "directSelection", (85.0, 40.0), (95.0, 60.0)); + assert!(sel.objects.is_empty() && sel.anchors.is_empty() && sel.segments.is_empty(), "{sel:?}"); + // Over the shadow and B's upper left corner: that anchor of B, and nothing of A. + let sel = marquee(&mut s, "directSelection", (90.0, 5.0), (105.0, 30.0)); + assert_eq!(sel.objects, [b]); + assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]); + // Across B's top edge under the shadow, with neither of its anchors inside: that segment. + run(&mut s, "select.none", json!({})); + assert_ne!(pixel(&s, 112, 25), BLUE); + let sel = marquee(&mut s, "directSelection", (110.0, 5.0), (115.0, 30.0)); + assert_eq!(sel.objects, [b]); + assert!(sel.segments.contains_key(&b) && !sel.segments.contains_key(&a), "{sel:?}"); + assert_eq!((bounds(&s, a), bounds(&s, b)), (a_before, b_before)); +} + +#[test] +fn direct_selection_hover_leaves_out_a_drop_shadow_and_marks_the_anchor_under_it() { + let mut s = session(); + let (_, b) = shadow_over_b(&mut s); + // B's upper left corner lies under the shadow. + assert_ne!(pixel(&s, 101, 21), BLUE); + run(&mut s, "select.none", json!({})); + s.select_tool("directSelection", ViewInfo::default()).unwrap(); + for (x, y) in [(90.0, 50.0), (110.0, 50.0)] { + assert_eq!(hover(&mut s, x, y), Cursor::ArrowHollow, "({x}, {y})"); + assert!(anchor_marks(&mut s).is_empty(), "({x}, {y})"); + } + assert_eq!(hover(&mut s, 101.0, 21.0), Cursor::DirectAnchor); + assert_eq!(anchor_marks(&mut s), [Point::new(100.0, 20.0)]); + // A press there picks that anchor of B. + assert_eq!(press(&mut s, "directSelection", (101.0, 21.0), (101.0, 21.0), Mods::default()), [b]); + assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]); +} + +#[test] +fn a_group_selection_click_on_a_drop_shadow_selects_the_object_under_it() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + assert_eq!(press(&mut s, "groupSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()), [b]); + assert!(press(&mut s, "groupSelection", (90.0, 50.0), (90.0, 50.0), Mods::default()).is_empty()); + assert_eq!(press(&mut s, "groupSelection", (50.0, 50.0), (50.0, 50.0), Mods::default()), [a]); +} + +#[test] +fn group_selection_clicks_on_a_drop_shadow_climb_the_groups_of_the_object_under_it() { + let mut s = session(); + // B and C in one group, A and D in another above it, and A's shadow over B. + let b = rect(&mut s, 100.0, 20.0, 60.0, 60.0, "#0000ff"); + let c = rect(&mut s, 170.0, 20.0, 20.0, 20.0, "#0000ff"); + run(&mut s, "select.set", json!({"ids": [b.0, c.0]})); + let gb = id_of(&run(&mut s, "object.group", json!({}))); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + let d = rect(&mut s, 20.0, 120.0, 20.0, 20.0, "#ff0000"); + run(&mut s, "select.set", json!({"ids": [a.0, d.0]})); + let ga = id_of(&run(&mut s, "object.group", json!({}))); + shadow(&mut s, a, 40.0); + assert_ne!(pixel(&s, 110, 50), BLUE); + assert_ne!(pixel(&s, 90, 50), WHITE); + run(&mut s, "select.none", json!({})); + let click = |s: &mut Session, x, y| { + let mut sel = press(s, "groupSelection", (x, y), (x, y), Mods::default()); + sel.sort_by_key(|n| n.0); + sel + }; + assert_eq!(click(&mut s, 110.0, 50.0), [b]); + let mut both = vec![b, gb]; + both.sort_by_key(|n| n.0); + assert_eq!(click(&mut s, 110.0, 50.0), both); + // With A selected, a click on its shadow over the canvas does not add A's group. + run(&mut s, "select.set", json!({"ids": [a.0]})); + assert!(click(&mut s, 90.0, 50.0).is_empty()); + // On A itself the second click adds it. + assert_eq!(click(&mut s, 50.0, 50.0), [a]); + let mut both = vec![a, ga]; + both.sort_by_key(|n| n.0); + assert_eq!(click(&mut s, 50.0, 50.0), both); +} + +#[test] +fn a_group_selection_marquee_leaves_out_a_drop_shadow() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + let sel = marquee(&mut s, "groupSelection", (85.0, 40.0), (95.0, 60.0)); + assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "{sel:?}"); + // Over the shadow and B's upper left corner: that anchor of B. + let sel = marquee(&mut s, "groupSelection", (90.0, 5.0), (105.0, 30.0)); + assert_eq!(sel.objects, [b]); + assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]); + // With E, a marquee around B that crosses A's shadow selects B whole, and not A. + run(&mut s, "select.none", json!({})); + let v = ViewInfo::default(); + s.select_tool("groupSelection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 95.0, 10.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 130.0, 50.0), v).unwrap(); + s.tool_key(vectorcraft_tools::ToolKey::Letter('e'), Mods::default(), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 165.0, 90.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 165.0, 90.0), v).unwrap(); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!(sel.objects, [b]); + assert!(sel.partial(b).is_none() && sel.partial(a).is_none(), "{sel:?}"); +} + +#[test] +fn group_selection_hover_over_a_drop_shadow_marks_nothing() { + let mut s = session(); + let (_, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + s.select_tool("groupSelection", ViewInfo::default()).unwrap(); + for (x, y) in [(90.0, 50.0), (110.0, 50.0), (101.0, 21.0)] { + assert_eq!(hover(&mut s, x, y), Cursor::GroupSelection, "({x}, {y})"); + assert!(anchor_marks(&mut s).is_empty(), "({x}, {y})"); + } + assert_eq!(press(&mut s, "groupSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()), [b]); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 351ade4468..db12554849 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -600,7 +600,7 @@ since 2026-10-01. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. - **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. -- **Clicks pass through raster effects:** clicks with the Selection and Direct Selection tools, and the Selection tool's pointer and marquee, leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. +- **Selection passes through raster effects:** clicks with the Selection, Direct Selection and Group Selection tools (Group Selection's repeated click included), the Selection tool's pointer and marquee, Direct Selection's marquee, pointer and anchor highlight, and Group Selection's marquee (E for enclosed objects included) leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, a repeated Group Selection click there adds the groups of the object under it, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). Over a shadow, Direct Selection highlights only the anchors of the object under it, and Group Selection keeps its own pointer and highlights no anchor, as it does everywhere (VectorCraft's own rule). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. - **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`. From 2ee2ac6245a55a0ffe830681594fb6484e2dc89e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:41:55 -0800 Subject: [PATCH 27/79] Objects on Path: the Select/Move widget chooses an object and drags it to another place along the path With one Objects on Path group selected, the Selection and Objects on Path tools show a ring on each object's attachment point. A click chooses that object while the group stays selected; dragging a ring along the path moves its object to the nearest object's place, and the objects between move one place toward where it was, in one undo step. Hidden and locked objects have no ring and keep their places. On a closed path the drag goes on round across the first anchor, and Escape cancels it. - object.objectsOnPath.selectMember {member} chooses an object. - object.objectsOnPath.moveMember {member, target, group?} moves an object to another object's place; only the order along the path changes. Docs: docs/mcp.md, docs/target-app-parity.md. Not yet: Detach. --- crates/doc/src/objects_on_path.rs | 48 +++ crates/doc/src/selection.rs | 31 ++ crates/doc/src/selection/tests.rs | 43 ++ crates/engine/src/cmd/objects_on_path.rs | 91 +++- crates/engine/src/tests/objects_on_path.rs | 133 ++++++ crates/tools/src/objects_on_path.rs | 458 ++++++++++++++++++++- crates/tools/src/select.rs | 31 +- crates/ui-egui/src/canvas/overlays.rs | 24 +- crates/ui-egui/src/canvas/tests.rs | 32 ++ docs/mcp.md | 24 ++ docs/target-app-parity.md | 3 +- 11 files changed, 899 insertions(+), 19 deletions(-) diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index cace68ce66..26a11cf733 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -180,6 +180,24 @@ impl ObjectsOnPath { }); Some((spacing, start)) } + /// Move the object `member` to the place on the path of the object `target`: the objects + /// between them each move one place toward where `member` was, except the objects for which + /// `fixed` is true, which keep their places and are passed over. Only the order changes, not the + /// Space or the start. Returns `member`'s new index, or `None` when either one is not an object + /// of the group. + pub fn move_member(&mut self, member: NodeId, target: NodeId, fixed: impl Fn(NodeId) -> bool) -> Option { + let moves = |id: NodeId| id == member || id == target || !fixed(id); + let slots: Vec = (0..self.members.len()).filter(|&i| moves(self.members[i].id)).collect(); + let from = slots.iter().position(|&i| self.members[i].id == member)?; + let to = slots.iter().position(|&i| self.members[i].id == target)?; + let mut moving: Vec<_> = slots.iter().map(|&i| self.members[i]).collect(); + let m = moving.remove(from); + moving.insert(to, m); + for (&i, m) in slots.iter().zip(moving) { + self.members[i] = m; + } + Some(slots[to]) + } /// The children given new ids (a copy of the group): the path and the objects by their new ids. pub fn rename(&mut self, ids: &HashMap) { let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id); @@ -487,6 +505,36 @@ mod tests { assert_eq!(layout(400.0, true, 4, Some(300.0), 400.0), (0.0, 100.0)); } + #[test] + fn moving_an_object_shifts_the_ones_between_it_and_its_new_place() { + let (a, b, c) = + (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0)), rect(4, Rect::new(40.0, 0.0, 50.0, 10.0))); + let rec = ObjectsOnPath { spacing: Some(30.0), start: 5.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b, &c], CENTER, 15.0) }; + let order = |r: &ObjectsOnPath| r.members.iter().map(|m| m.id.0).collect::>(); + let free = |_: NodeId| false; + let mut fwd = rec.clone(); + assert_eq!(fwd.move_member(NodeId(2), NodeId(4), free), Some(2)); + assert_eq!(order(&fwd), [3, 4, 2], "the first to the last place"); + assert_eq!((fwd.spacing, fwd.start, fwd.pivot, fwd.rotate), (rec.spacing, rec.start, rec.pivot, rec.rotate)); + let mut back = rec.clone(); + assert_eq!(back.move_member(NodeId(4), NodeId(2), free), Some(0)); + assert_eq!(order(&back), [4, 2, 3], "the last to the first place"); + let mut same = rec.clone(); + assert_eq!(same.move_member(NodeId(3), NodeId(3), free), Some(1)); + assert_eq!(same, rec, "onto its own place"); + let mut other = rec.clone(); + assert_eq!(other.move_member(NodeId(1), NodeId(3), free), None, "the path is not one of the objects"); + assert_eq!(other.move_member(NodeId(3), NodeId(9), free), None); + assert_eq!(other, rec); + // A fixed object between them keeps its place; the others pass over it. + let mut over = rec.clone(); + assert_eq!(over.move_member(NodeId(2), NodeId(4), |id| id == NodeId(3)), Some(2)); + assert_eq!(order(&over), [4, 3, 2], "the first and last trade places round the fixed one"); + let mut back_over = rec.clone(); + assert_eq!(back_over.move_member(NodeId(4), NodeId(2), |id| id == NodeId(3)), Some(0)); + assert_eq!(order(&back_over), [4, 3, 2]); + } + #[test] fn a_group_saved_without_space_reads_as_auto() { let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index c514aab996..6f0db556e0 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -59,6 +59,11 @@ pub struct Selection { /// that object ([`crate::symbol_overrides`]). Any other selection change clears it. #[serde(default, skip_serializing_if = "Option::is_none")] pub symbol_part: Option, + /// An object of an Objects on Path group, chosen with its Select/Move widget: the group is the + /// one selected object, and the object is one of its objects on the path + /// ([`crate::objects_on_path::ObjectsOnPath::members`]). Any other selection change clears it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_path_member: Option, } /// See [`Selection::symbol_part`]. @@ -94,6 +99,13 @@ impl Selection { self.guides.clear(); self.key_guide = None; self.symbol_part = None; + self.on_path_member = None; + } + /// Select only the Objects on Path group `group`, with its object `member` (see + /// [`Selection::on_path_member`]). + pub fn set_on_path_member(&mut self, group: NodeId, member: NodeId) { + self.set([group]); + self.on_path_member = Some(member); } /// Select only instance `instance`, with the object at `path` of its symbol's art (see /// [`Selection::symbol_part`]). @@ -115,6 +127,7 @@ impl Selection { self.target = None; self.slices.clear(); self.symbol_part = None; + self.on_path_member = None; if !self.objects.contains(&id) { self.objects.push(id); } @@ -123,6 +136,7 @@ impl Selection { self.target = None; self.slices.clear(); self.symbol_part = None; + self.on_path_member = None; self.objects.retain(|x| *x != id); self.anchors.remove(&id); self.segments.remove(&id); @@ -152,6 +166,7 @@ impl Selection { /// Shift-click on a guide). pub fn toggle_guide(&mut self, i: NodeId) { self.symbol_part = None; + self.on_path_member = None; if let Some(k) = self.guides.iter().position(|g| *g == i) { self.guides.remove(k); if self.key_guide == Some(i) { @@ -258,6 +273,22 @@ impl Selection { if !part_kept { self.symbol_part = None; } + // A chosen object on a path needs its Objects on Path group selected alone, with no anchors + // or guides, and still one of the group's objects, neither hidden nor locked. + let member_kept = self.on_path_member.is_some_and(|m| { + let [g] = self.objects.as_slice() else { return false }; + self.anchors.is_empty() + && self.guides.is_empty() + && doc.is_editable(m) + && found + .get(g) + .and_then(|p| doc.node_at(p)) + .and_then(|n| n.extras().objects_on_path.as_deref().filter(|_| crate::objects_on_path::is_objects_on_path(n))) + .is_some_and(|rec| rec.members.iter().any(|x| x.id == m)) + }); + if !member_kept { + self.on_path_member = None; + } } /// Deselect the objects that are hidden or locked, themselves or through a group or layer /// around them (and so the key object when it is one): hidden or locked art can't stay diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index d0d81faee6..d9df845129 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -179,6 +179,49 @@ fn selection_changes_clear_the_target() { assert_eq!(s.target, None); } +#[test] +fn a_chosen_object_on_a_path_needs_its_group_selected_alone() { + use vectorcraft_geom::{Point, Rect, shapes}; + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (line, a, b, g, other) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + let line = Node::path(line, shapes::line(Point::new(0.0, 100.0), Point::new(200.0, 100.0)), Default::default()); + let a = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Default::default()); + let b = Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Default::default()); + let rec = crate::objects_on_path::ObjectsOnPath::new(line.id, &[&a, &b], crate::objects_on_path::CENTER, 0.0); + let (line_id, a_id) = (line.id, a.id); + let mut group = Node::new(g, NodeKind::Group { children: vec![Arc::new(line), Arc::new(a), Arc::new(b)], clip: false }); + group.extras_mut().objects_on_path = Some(Box::new(rec)); + d.insert(Some(l), 0, group).unwrap(); + d.insert(Some(l), 1, Node::path(other, shapes::rectangle(Rect::new(150.0, 0.0, 160.0, 10.0)), Default::default())).unwrap(); + let mut s = Selection::default(); + s.set_on_path_member(g, a_id); + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], Some(a_id))); + s.add(other); + assert_eq!(s.on_path_member, None, "another object joins the selection"); + s.set_on_path_member(g, a_id); + s.set([g]); + assert_eq!(s.on_path_member, None, "a new selection"); + // The path is not one of the objects, nor is an object outside the group. + for m in [line_id, other] { + s.set_on_path_member(g, m); + s.prune(&d); + assert_eq!(s.on_path_member, None, "{m:?}"); + } + // A locked object is not chosen. + s.set_on_path_member(g, a_id); + d.node_mut(a_id).unwrap().locked = true; + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], None), "locked"); + d.node_mut(a_id).unwrap().locked = false; + // Once the group is a plain group, no object of it is chosen. + s.set_on_path_member(g, a_id); + d.node_mut(g).unwrap().extras_mut().objects_on_path = None; + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], None)); +} + #[test] fn a_key_object_needs_two_or_more_selected_objects() { let mut s = Selection::default(); diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index 1a36a4e29f..296cef4dc2 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -9,6 +9,10 @@ //! from the tool's options (Objects on Path Tool Options). `object.objectsOnPath.options` sets the //! group's Pivot, Rotate and Space (the distance between the objects along the path, and where the //! first one attaches), and `object.objectsOnPath.expand` makes it a plain group. +//! `object.objectsOnPath.selectMember` chooses one object of a selected group (its Select/Move +//! widget), and `object.objectsOnPath.moveMember` moves one object to another's place along the +//! path, the objects between it and that place each moving one place toward where it was; hidden +//! and locked objects keep their places. //! //! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back //! on its path: editing the path moves them, an object moved on its own goes back to its place, @@ -19,7 +23,7 @@ //! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution, //! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; @@ -28,7 +32,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use vectorcraft_geom::Rect; use super::edit::{roots_of, selected_roots}; -use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_selection}; +use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_doc, has_selection}; /// The id of the Objects on Path tool. pub const TOOL: &str = "objectsOnPath"; @@ -53,6 +57,24 @@ pub fn specs() -> Vec { has_objects_on_path, options ), + cmd!( + "object.objectsOnPath.selectMember", + "Select Object on Path", + [], + None, + "{member: id of an object of an Objects on Path group} select the group alone with that object chosen, as a click on the object's Select/Move widget does; any other selection change clears the choice → {group, member}", + has_doc, + select_member + ), + cmd!( + "object.objectsOnPath.moveMember", + "Move Object on Path", + [], + None, + "{member: id of an object of an Objects on Path group, target: id of another object of the same group, group?: the group's id (when given, it must be the group of both); neither object nor the group may be hidden or locked} move the object to the target's place along the path; the objects between them each move one place toward where it was, except hidden and locked objects, which keep their places. Only the order along the path changes: Space, the start, Pivot, Rotate and the order in the Layers panel stay. A target that is the object itself changes nothing → {group, member, index}", + has_doc, + move_member + ), cmd!( "object.objectsOnPath.expand", "Expand", @@ -296,6 +318,71 @@ fn options(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::>() })) } +// ---------- Select/Move ---------- + +/// The Objects on Path group whose object `key` of `p` names, checked: an object listed in the +/// group's record (not its path), in a group that is not hidden or locked. +fn member_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<(NodeId, NodeId)> { + let v = p.get(key).filter(|v| !v.is_null()).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?; + let id = checked_id(s, v, cmd)?; + let d = &s.doc()?.doc; + let not_on_path = || bad(cmd, format!("`{key}` must name an object attached to the path of an Objects on Path group")); + let g = + d.ancestry(id).unwrap_or_default().into_iter().rev().nth(1).filter(|g| d.node(*g).is_some_and(is_objects_on_path)).ok_or_else(not_on_path)?; + let listed = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref()).is_some_and(|rec| rec.members.iter().any(|m| m.id == id)); + if !listed { + return Err(not_on_path()); + } + if !d.is_editable(g) { + return Err(bad(cmd, "the Objects on Path group is hidden or locked")); + } + if !d.is_editable(id) { + return Err(bad(cmd, format!("`{key}` names an object that is hidden or locked"))); + } + Ok((g, id)) +} + +fn select_member(s: &mut Session, p: &Value) -> Result { + let (g, member) = member_param(s, p, "member", "object.objectsOnPath.selectMember")?; + s.select(|_, sel| sel.set_on_path_member(g, member))?; + Ok(json!({ "group": g.0, "member": member.0 })) +} + +fn move_member(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.objectsOnPath.moveMember"; + let (g, member) = member_param(s, p, "member", C)?; + let (target_group, target) = member_param(s, p, "target", C)?; + if target_group != g { + return Err(bad(C, "`target` must be an object of the same Objects on Path group as `member`")); + } + if let Some(v) = p.get("group").filter(|v| !v.is_null()) + && checked_id(s, v, C)? != g + { + return Err(bad(C, "`group` must be the Objects on Path group of `member` and `target`")); + } + let at = |s: &Session| -> Result { + let rec = s.doc()?.doc.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?; + rec.members.iter().position(|m| m.id == member).ok_or(EngineError::NoNode(member)) + }; + // Onto its own place: nothing changes, and no undo step is recorded. + if target == member { + return Ok(json!({ "group": g.0, "member": member.0, "index": at(s)? })); + } + // Hidden and locked objects between them keep their places. + let index = s.edit("Move Object on Path", |d, _| { + let mut rec = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?; + let fixed: HashSet = { + let editable = d.editable(); + rec.members.iter().map(|m| m.id).filter(|&id| !editable(id)).collect() + }; + let index = rec.move_member(member, target, |id| fixed.contains(&id)).ok_or(EngineError::NoNode(member))?; + let n = d.node_mut(g).ok_or(EngineError::NoNode(g))?; + n.extras_mut().objects_on_path = Some(Box::new(rec)); + Ok(index) + })?; + Ok(json!({ "group": g.0, "member": member.0, "index": index })) +} + // ---------- Expand ---------- fn expand(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests/objects_on_path.rs b/crates/engine/src/tests/objects_on_path.rs index 8c0f359df4..4ae8321167 100644 --- a/crates/engine/src/tests/objects_on_path.rs +++ b/crates/engine/src/tests/objects_on_path.rs @@ -531,6 +531,139 @@ fn a_space_that_is_not_usable_is_refused_and_one_too_large_is_kept_to_the_path() assert_eq!(space(&s, g), (Some(100.0), 0.0)); } +/// The ids of group `g`'s objects in their order along the path, and of its children in the +/// Layers panel's order. +fn orders(s: &Session, g: NodeId) -> (Vec, Vec) { + let n = s.doc().unwrap().doc.node(g).unwrap(); + let path = n.extras().objects_on_path.as_deref().unwrap().members.iter().map(|m| m.id).collect(); + (path, n.children().unwrap().iter().map(|c| c.id).collect()) +} + +/// Three objects of different widths (20, 40 and 10 pt) attached to a 300 pt line from (50, 300), +/// in that order, with a Space of 100 pt from 0, Pivot 4 and Rotate 0. +fn three_on_a_line(s: &mut Session) -> (NodeId, [NodeId; 3], NodeId) { + let a = rect(s, 0.0, 0.0, 20.0, 20.0); + let b = rect(s, 100.0, 0.0, 40.0, 20.0); + let c = rect(s, 200.0, 0.0, 10.0, 20.0); + let path = line(s, (50.0, 300.0), (350.0, 300.0)); + let g = attach(s, path, &[a, b, c], json!({})); + s.execute("object.objectsOnPath.options", &json!({"spacing": 100})).unwrap(); + (g, [a, b, c], path) +} + +#[test] +fn move_member_takes_the_target_s_place_and_shifts_the_objects_between() { + let mut s = session(); + let (g, [a, b, c], path) = three_on_a_line(&mut s); + let (_, layers) = orders(&s, g); + let before = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": c.0})).unwrap(); + assert_eq!(v, json!({"group": g.0, "member": a.0, "index": 2})); + assert_eq!(orders(&s, g), (vec![b, c, a], layers.clone()), "the order along the path changes, the Layers order does not"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + assert_eq!(center(&s, b), Point::new(50.0, 300.0)); + assert_eq!(center(&s, c), Point::new(150.0, 300.0)); + assert_eq!(center(&s, a), Point::new(250.0, 300.0)); + let after = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap(); + assert_eq!( + (after.path, after.pivot, after.rotate, after.spacing, after.start), + (path, before.pivot, before.rotate, before.spacing, before.start) + ); + let w = bounds(&s, a); + assert!((w.width() - 20.0).abs() < 1e-9, "the object keeps its art: {w:?}"); + // The last one back to the first place, with its group named. + let v = s.execute("object.objectsOnPath.moveMember", &json!({"group": g.0, "member": a.0, "target": b.0})).unwrap(); + assert_eq!(v["index"], 0); + assert_eq!(orders(&s, g).0, [a, b, c]); + assert_eq!(center(&s, a), Point::new(50.0, 300.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [b, c, a]); + assert_eq!(center(&s, a), Point::new(250.0, 300.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [a, b, c]); + assert_eq!(center(&s, c), Point::new(250.0, 300.0)); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [b, c, a]); + // Onto its own place: nothing changes and no step is recorded. + let steps = s.doc().unwrap().history.undo.len(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": c.0, "target": c.0})).unwrap(); + assert_eq!(v["index"], 1); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // The order saves and reopens. + let data = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "objects.vectorcraft", "dataBase64": data})).unwrap(); + assert_eq!(orders(&o, g), (vec![b, c, a], layers)); + assert_eq!(center(&o, a), Point::new(250.0, 300.0)); +} + +#[test] +fn move_member_refuses_what_is_not_an_object_on_the_same_path() { + let mut s = session(); + let (g, [a, b, c], path) = three_on_a_line(&mut s); + let loose = rect(&mut s, 300.0, 20.0, 10.0, 10.0); + let x = rect(&mut s, 0.0, 200.0, 10.0, 10.0); + let other_line = line(&mut s, (50.0, 380.0), (350.0, 380.0)); + let h = attach(&mut s, other_line, &[x], json!({})); + let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.moveMember", &p).unwrap_err().to_string(); + assert!(err(&mut s, json!({"member": path.0, "target": a.0})).contains("`member` must name an object attached"), "the path"); + assert!(err(&mut s, json!({"member": a.0, "target": path.0})).contains("`target` must name"), "the path as the target"); + assert!(err(&mut s, json!({"member": loose.0, "target": a.0})).contains("`member` must name"), "an object on no path"); + assert!(err(&mut s, json!({"member": g.0, "target": a.0})).contains("`member` must name"), "the group itself"); + assert!(err(&mut s, json!({"member": a.0, "target": x.0})).contains("same Objects on Path group"), "another group's object"); + assert!(err(&mut s, json!({"member": a.0, "target": b.0, "group": h.0})).contains("`group` must be"), "another group named"); + assert!(err(&mut s, json!({"target": b.0})).contains("missing `member`")); + assert!(err(&mut s, json!({"member": a.0})).contains("missing `target`")); + assert!(err(&mut s, json!({"member": 99999, "target": b.0})).contains("no such object")); + assert!(err(&mut s, json!({"member": "a", "target": b.0})).contains("invalid object id")); + // A locked object neither moves nor gives its place, and is not chosen. + select(&mut s, &[c]); + s.execute("object.lock", &json!({})).unwrap(); + assert!(err(&mut s, json!({"member": c.0, "target": a.0})).contains("`member` names an object that is hidden or locked")); + assert!(err(&mut s, json!({"member": a.0, "target": c.0})).contains("`target` names an object that is hidden or locked")); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": c.0})).is_err()); + s.execute("object.unlockAll", &json!({})).unwrap(); + // A locked object between the two keeps its place, and the others pass over it. + select(&mut s, &[b]); + s.execute("object.lock", &json!({})).unwrap(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": c.0})).unwrap(); + assert_eq!(v["index"], 2); + assert_eq!(orders(&s, g).0, [c, b, a]); + assert_eq!(center(&s, b), Point::new(150.0, 300.0), "the locked object stays where it was"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.unlockAll", &json!({})).unwrap(); + select(&mut s, &[g]); + s.execute("object.lock", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(g).unwrap().locked); + let e = err(&mut s, json!({"member": a.0, "target": b.0})); + assert!(e.contains("hidden or locked"), "a locked group: {e}"); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": a.0})).is_err(), "a locked group's object is not chosen"); + assert_eq!(orders(&s, g).0, [a, b, c], "nothing refused changed the order"); +} + +#[test] +fn select_member_selects_the_group_with_the_object_chosen() { + let mut s = session(); + let (g, [a, b, _], path) = three_on_a_line(&mut s); + let v = s.execute("object.objectsOnPath.selectMember", &json!({"member": b.0})).unwrap(); + assert_eq!(v, json!({"group": g.0, "member": b.0})); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.objects.as_slice(), sel.on_path_member), (&[g][..], Some(b))); + // Moving the chosen object keeps it chosen. + s.execute("object.objectsOnPath.moveMember", &json!({"member": b.0, "target": a.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.on_path_member, Some(b)); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": path.0})).is_err()); + select(&mut s, &[g]); + assert_eq!(s.doc().unwrap().selection.on_path_member, None, "another selection clears it"); + // Both take their objects without a selection. + select(&mut s, &[]); + s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": b.0})).unwrap(); + assert_eq!(orders(&s, g).0[..2], [a, b]); + s.execute("object.objectsOnPath.selectMember", &json!({"member": a.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.on_path_member, Some(a)); +} + #[test] fn the_space_saves_and_reopens_and_goes_with_a_copy() { let mut s = session(); diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index c2f90d0bef..a60c9fb15a 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -29,12 +29,24 @@ //! distance along the path shows next to the pointer in the document's units, the drag is //! previewed and kept as one undo step on release, and Escape cancels it. //! +//! The Select/Move widgets: with one Objects on Path group selected, the same tools show a small +//! ring on each object's attachment point, highlighted under the pointer. A click on one chooses +//! that object (`object.objectsOnPath.selectMember`), and its ring is filled; the group stays +//! selected, and a click on the object's art still selects the group. Dragging a ring along the +//! path moves its object to the place of the object nearest the pointer, and the objects between +//! move one place toward where it was (`object.objectsOnPath.moveMember`): only their order along +//! the path changes, and hidden and locked objects keep their places. On an open path a pointer beyond an end takes the place at that end; on a +//! closed path the drag goes on round across the first anchor. The drag is previewed and kept as +//! one undo step on release, and Escape cancels it. +//! //! Behavior: , //! . use serde_json::{Value, json}; use vectorcraft_doc::blend::Spine; -use vectorcraft_doc::objects_on_path::{CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop}; +use vectorcraft_doc::objects_on_path::{ + CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop, stops, +}; use vectorcraft_doc::{Document, Node, NodeId, Selection}; use vectorcraft_geom::{BezPath, Point, Vec2}; @@ -68,6 +80,11 @@ pub const MOVE_ALL_HALF_PX: f32 = 4.5; /// How near (screen px) the pointer must be to hit the Move All widget. const MOVE_ALL_HIT_PX: f64 = 6.0; +/// The radius (screen px) of the Select/Move widget's ring, as the canvas draws it. +pub const MEMBER_RADIUS_PX: f32 = 4.5; +/// How near (screen px) the pointer must be to hit a Select/Move widget. +const MEMBER_HIT_PX: f64 = 6.0; + /// The Objects on Path group the widgets show for at `zoom` (screen px per document point), with /// its record, its children and its path: only when the selection is that one editable group, on /// a path with length, and no anchors are Direct-Selected. @@ -567,6 +584,180 @@ impl MoveAllDrag { } } +/// The Select/Move widget of one object of a selected Objects on Path group: a small ring on the +/// object's attachment point. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MemberWidget { + pub group: NodeId, + /// The object. + pub member: NodeId, + /// The object's place along the path (0: the first). + pub index: usize, + /// The object's attachment point, in the document: the widget's center. + pub at: Point, + /// Is the object the chosen one ([`Selection::on_path_member`])? + pub chosen: bool, +} + +impl MemberWidget { + /// The widgets of the selection at `zoom` (screen px per document point), one per object in + /// its order along the path that is neither hidden nor locked: only when the selection is one + /// editable Objects on Path group on a path with length, and no anchors are Direct-Selected. + pub fn all(doc: &Document, selection: &Selection, zoom: f64) -> Vec { + let Some((group, rec, _, path)) = one_group(doc, selection, zoom) else { return vec![] }; + let Some(at) = stops(path, rec.members.len(), rec.spacing, rec.start) else { return vec![] }; + let editable = doc.editable(); + rec.members + .iter() + .zip(at) + .enumerate() + .filter(|(_, (m, (p, _)))| p.x.is_finite() && p.y.is_finite() && editable(m.id)) + .map(|(index, (m, (at, _)))| Self { group, member: m.id, index, at, chosen: selection.on_path_member == Some(m.id) }) + .collect() + } + + /// The widget of `cx` nearest `p`, when `p` is within reach of it; of widgets as near, the + /// chosen one, else the first along the path. + fn at_point(cx: &ToolContext, p: Point) -> Option { + let reach = cx.tol(MEMBER_HIT_PX); + // Every attachment point lies on the path: a pointer beyond reach of its bounds is over none. + let (_, _, _, path) = one_group(cx.doc, cx.selection, cx.zoom)?; + if !path.control_bounds()?.inflate(reach, reach).contains(p) { + return None; + } + let mut best: Option<(f64, Self)> = None; + for w in Self::all(cx.doc, cx.selection, cx.zoom) { + let d = w.at.distance(p); + let nearer = best.is_none_or(|(b, bw)| d < b - 1e-9 || (d <= b + 1e-9 && w.chosen && !bw.chosen)); + if d <= reach && nearer { + best = Some((d, w)); + } + } + best.map(|(_, w)| w) + } +} + +/// Is `p` over a Select/Move widget? +pub fn over_member(cx: &ToolContext, p: Point) -> bool { + MemberWidget::at_point(cx, p).is_some() +} + +/// The highlight of the Select/Move widget under `p`, if any: a ring around it. +pub fn member_hover(cx: &ToolContext, p: Point) -> Option { + let w = MemberWidget::at_point(cx, p)?; + let r = cx.tol(f64::from(MEMBER_RADIUS_PX) + 2.0); + let ring = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(w.at.x - r, w.at.y - r, w.at.x + r, w.at.y + r)); + Some(Overlay::Path { path: ring.to_bezpath(), color: HIGHLIGHT, width: 1.5, dashed: false }) +} + +/// Dragging a Select/Move widget: the object goes to the place along the path nearest the +/// pointer, and the objects between it and its place move one place toward where it was, except +/// hidden and locked ones (`object.objectsOnPath.moveMember`). On an open path a pointer beyond +/// an end takes the place at that end; on a closed path the pointer goes on round across the first +/// anchor. Where two places are as near, the one the drag is at stays. A click without a drag, and +/// the release of a drag, choose the object (`object.objectsOnPath.selectMember`). +#[derive(Clone, Debug)] +pub struct MemberDrag { + widget: MemberWidget, + /// The objects in their order along the path when the press began. + order: Vec, + /// The distance along the path to each place (points), in that order. + places: Vec, + /// Can the drag take each place? Not the place of a hidden or locked object. + open: Vec, + /// The length of the path (points). + len: f64, + /// Is the path closed? + closed: bool, + start: Point, + began: bool, + /// The distance along the path to the point nearest the pointer at the last drag (points). + last: f64, + /// The place the drag puts the object at. + target: usize, +} + +impl MemberDrag { + /// The drag has left the click: the object is moving. + pub fn adjusting(&self) -> bool { + self.began + } + + /// Start a drag when the press `ev` is on a Select/Move widget. + pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { + let widget = MemberWidget::at_point(cx, ev.pos)?; + let n = cx.doc.node(widget.group)?; + let rec = n.extras().objects_on_path.as_deref()?; + let (len, closed) = path_length(attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?)?; + let count = rec.members.len(); + let (s0, gap) = layout(len, closed, count, rec.spacing, rec.start); + let places: Vec = (0..count).map(|i| nth_length(len, closed, s0, gap, i)).collect(); + let last = *places.get(widget.index)?; + let editable = cx.doc.editable(); + let open = rec.members.iter().map(|m| editable(m.id)).collect(); + let order = rec.members.iter().map(|m| m.id).collect(); + Some(Self { widget, order, places, open, len, closed, start: ev.pos, began: false, last, target: widget.index }) + } + + /// How far apart along the path the distances `a` and `b` are: the shorter way round a closed + /// path. + fn apart(&self, a: f64, b: f64) -> f64 { + let d = (a - b).abs(); + if self.closed { d.rem_euclid(self.len).min(self.len - d.rem_euclid(self.len)) } else { d } + } + + /// The place nearest the distance `t` along the path, of the places the drag can take; the + /// current one where another is as near. + fn nearest_place(&self, t: f64) -> usize { + let t = if self.closed { t } else { t.clamp(0.0, self.len) }; + let now = self.places.get(self.target).map_or(f64::INFINITY, |s| self.apart(t, *s)); + let mut best = (self.target, now); + for (i, s) in self.places.iter().enumerate().filter(|(i, _)| self.open.get(*i).copied().unwrap_or(false)) { + let d = self.apart(t, *s); + if d.is_finite() && d < best.1 - 1e-9 { + best = (i, d); + } + } + best.0 + } + + /// The pointer moved to `p`. + pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec { + let mut out = vec![]; + if !self.began { + if p.distance(self.start) < cx.tol(3.0) { + return out; + } + self.began = true; + out.push(Action::Begin("Move Object on Path".into())); + } + let w = &self.widget; + let Some((_, t)) = group_spine(cx.doc, w.group).and_then(|s| s.nearest(p, Some(self.last))) else { return out }; + if !t.is_finite() { + return out; + } + self.last = t; + let target = self.nearest_place(t); + if target != self.target { + self.target = target; + let to = self.order.get(target).copied().unwrap_or(w.member); + out.push(Action::Preview("object.objectsOnPath.moveMember".into(), json!({ "group": w.group.0, "member": w.member.0, "target": to.0 }))); + } + out + } + + /// The release: a drag that moved the object keeps it as one undo step, one that ends where it + /// began keeps nothing, and either way the object is chosen. + pub fn finish(self) -> Vec { + let choose = Action::Exec("object.objectsOnPath.selectMember".into(), json!({ "member": self.widget.member.0 })); + match (self.began, self.target == self.widget.index) { + (false, _) => vec![choose], + (true, true) => vec![Action::Cancel, choose], + (true, false) => vec![Action::Commit, choose], + } + } +} + /// The path of the Objects on Path group `g` of `doc`, to measure along. fn group_spine(doc: &Document, g: NodeId) -> Option { let n = doc.node(g)?; @@ -602,6 +793,10 @@ pub struct ObjectsOnPathTool { spacing: Option, /// The Move All widget drag, while the button is down after a press on the widget. moving: Option, + /// The Select/Move widget drag, while the button is down after a press on a widget. + member: Option, + /// Where the pointer is while no button is down, for the Select/Move widget it is over. + pointer: Option, /// The reference point of each object attached next (0-8, row by row from the top left). pivot: u8, /// The angle of each object attached next, in degrees counter-clockwise from upright on the path. @@ -610,7 +805,7 @@ pub struct ObjectsOnPathTool { impl Default for ObjectsOnPathTool { fn default() -> Self { - Self { hover: None, press: None, rotating: None, spacing: None, moving: None, pivot: CENTER, rotate: 0.0 } + Self { hover: None, press: None, rotating: None, spacing: None, moving: None, member: None, pointer: None, pivot: CENTER, rotate: 0.0 } } } @@ -642,9 +837,18 @@ impl Tool for ObjectsOnPathTool { _ => vec![], }; } + if let Some(d) = &mut self.member { + return match ev.kind { + PointerKind::Drag => d.drag(cx, p), + PointerKind::Up => self.member.take().map(MemberDrag::finish).unwrap_or_default(), + _ => vec![], + }; + } + self.pointer = (ev.kind == PointerKind::Move).then_some(p); match ev.kind { PointerKind::Move => { - self.hover = if over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) { None } else { target(cx, p) }; + let widget = over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) || over_member(cx, p); + self.hover = if widget { None } else { target(cx, p) }; vec![] } PointerKind::Down => { @@ -663,6 +867,11 @@ impl Tool for ObjectsOnPathTool { self.hover = None; return vec![]; } + if let Some(d) = MemberDrag::hit(cx, ev) { + self.member = Some(d); + self.hover = None; + return vec![]; + } self.press = Some(p); vec![] } @@ -689,16 +898,22 @@ impl Tool for ObjectsOnPathTool { if let Some(d) = &self.moving { o.extend(d.overlays(cx)); } + if let Some(p) = self.pointer.filter(|_| !self.busy()) { + o.extend(member_hover(cx, p)); + } o } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { + if self.member.is_some() { + return Cursor::Move; + } let idle = self.spacing.is_none() && self.moving.is_none(); if self.rotating.is_some() || (idle && over_rotate_all(cx, p)) { Cursor::Rotate } else if self.spacing.is_some() || (self.moving.is_none() && over_space(cx, p)) { Cursor::ArrowHollow - } else if self.moving.is_some() || over_move_all(cx, p) { + } else if self.moving.is_some() || over_move_all(cx, p) || over_member(cx, p) { Cursor::Move } else if target(cx, p).is_some() { Cursor::ObjectsOnPath @@ -708,8 +923,8 @@ impl Tool for ObjectsOnPathTool { } /// Escape while the button is down cancels the click, so the release attaches nothing, or the - /// Rotate All, Space or Move All drag, so the objects go back to the Rotate, Space or place - /// they had. + /// Rotate All, Space, Move All or Select/Move drag, so the objects go back to the Rotate, Space, + /// place or order they had. fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Escape { self.press = None; @@ -717,7 +932,8 @@ impl Tool for ObjectsOnPathTool { let rotated = self.rotating.take().is_some_and(|d| d.adjusting()); let spaced = self.spacing.take().is_some_and(|d| d.adjusting()); let moved = self.moving.take().is_some_and(|d| d.adjusting()); - if rotated || spaced || moved { + let ordered = self.member.take().is_some_and(|d| d.adjusting()); + if rotated || spaced || moved || ordered { return vec![Action::Cancel]; } } @@ -747,13 +963,14 @@ impl Tool for ObjectsOnPathTool { } fn busy(&self) -> bool { - self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() || self.moving.is_some() + self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() || self.moving.is_some() || self.member.is_some() } fn transforming(&self) -> bool { self.rotating.as_ref().is_some_and(RotateAllDrag::adjusting) || self.spacing.as_ref().is_some_and(SpaceDrag::adjusting) || self.moving.as_ref().is_some_and(MoveAllDrag::adjusting) + || self.member.as_ref().is_some_and(MemberDrag::adjusting) } } @@ -1469,4 +1686,229 @@ mod tests { assert!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)).is_empty(), "{}: the release keeps nothing", t.id()); } } + + /// A document holding an Objects on Path group of `n` 20 × 20 squares attached by their centers + /// to `path`, with the group's Space `spacing`, a selection of the group, and the squares' ids + /// in their order along the path. + fn members_doc(path: vectorcraft_geom::PathData, n: usize, spacing: Option) -> (Document, NodeId, Selection, Vec) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (line, g) = (d.alloc_id(), d.alloc_id()); + let line = Node::path(line, path, Appearance::default_art()); + let squares: Vec = (0..n) + .map(|i| { + let x = 50.0 * i as f64; + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 50.0, x + 20.0, 70.0)), Appearance::default_art()) + }) + .collect(); + let ids: Vec = squares.iter().map(|s| s.id).collect(); + let rec = vectorcraft_doc::objects_on_path::ObjectsOnPath { + spacing, + ..vectorcraft_doc::objects_on_path::ObjectsOnPath::new(line.id, &squares.iter().collect::>(), CENTER, 0.0) + }; + let mut children = vec![Arc::new(line)]; + children.extend(squares.into_iter().map(Arc::new)); + let mut group = Node::new(g, NodeKind::Group { children, clip: false }); + group.extras_mut().objects_on_path = Some(Box::new(rec)); + vectorcraft_doc::objects_on_path::place(&mut group, &Document::new(1.0, 1.0).symbol_boxes()); + d.insert(Some(l), 0, group).unwrap(); + let mut sel = Selection::default(); + sel.set([g]); + (d, g, sel, ids) + } + + /// Three squares on the line from (0, 300) to (400, 300): at 0, 200 and 400. + fn three_doc() -> (Document, NodeId, Selection, Vec) { + members_doc(shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), 3, None) + } + + /// The `target` a drag's actions preview for `object.objectsOnPath.moveMember`, if any. + fn previewed_target(acts: &[Action]) -> Option { + acts.iter().rev().find_map(|a| match a { + Action::Preview(cmd, p) if cmd == "object.objectsOnPath.moveMember" => p["target"].as_u64(), + _ => None, + }) + } + + fn choose(id: NodeId) -> Action { + Action::Exec("object.objectsOnPath.selectMember".into(), json!({ "member": id.0 })) + } + + #[test] + fn a_select_move_widget_sits_on_each_attachment_point() { + let (d, g, sel, ids) = three_doc(); + let ws = MemberWidget::all(&d, &sel, 1.0); + let at: Vec<(NodeId, usize, Point, bool)> = ws.iter().map(|w| (w.member, w.index, w.at, w.chosen)).collect(); + assert!(ws.iter().all(|w| w.group == g)); + assert_eq!(at.len(), 3); + for (i, x) in [0.0, 200.0, 400.0].into_iter().enumerate() { + assert!(at[i].0 == ids[i] && at[i].1 == i && near(at[i].2, Point::new(x, 300.0)) && !at[i].3, "{at:?}"); + } + let mut chosen = sel.clone(); + chosen.on_path_member = Some(ids[1]); + assert_eq!(MemberWidget::all(&d, &chosen, 1.0).iter().map(|w| w.chosen).collect::>(), [false, true, false]); + // None for an object in the group, with anchors Direct-Selected, or for a locked group. + let mut one = Selection::default(); + one.set([ids[0]]); + assert!(MemberWidget::all(&d, &one, 1.0).is_empty()); + let mut anchors = sel.clone(); + anchors.anchors.insert(ids[0], [(0, 0)].into()); + assert!(MemberWidget::all(&d, &anchors, 1.0).is_empty()); + let (mut locked, lg, lsel, _) = three_doc(); + locked.node_mut(lg).unwrap().locked = true; + assert!(MemberWidget::all(&locked, &lsel, 1.0).is_empty()); + } + + /// A click on a widget chooses its object without moving it, with the Selection tool and the + /// Objects on Path tool; a click on the object's art away from the widget does not. + #[test] + fn a_click_on_a_select_move_widget_chooses_its_object() { + let (d, _, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Move, 202.0, 301.0)); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Path { color, .. } if *color == HIGHLIGHT)), "{}: highlighted", t.id()); + assert_eq!(t.cursor(&c, Point::new(202.0, 301.0), Mods::default()), Cursor::Move, "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Down, 202.0, 301.0)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 203.0, 301.0)).is_empty(), "{}: within the click", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 203.0, 301.0)), vec![choose(ids[1])], "{}", t.id()); + assert!(!t.busy()); + // On the art, 8 px from the attachment point: not the widget. + t.pointer(&c, &ev(PointerKind::Down, 208.0, 300.0)); + let acts = t.pointer(&c, &ev(PointerKind::Up, 208.0, 300.0)); + assert!(!acts.contains(&choose(ids[1])), "{}: {acts:?}", t.id()); + } + } + + /// A widget at an end of the path is within reach from beyond the path's bounds too, up to + /// 6 px away. + #[test] + fn a_select_move_widget_at_an_end_is_hit_from_beyond_the_path() { + let (d, _, sel, _) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + assert!(over_member(&c, Point::new(-5.0, 300.0)), "before the start"); + assert!(over_member(&c, Point::new(405.0, 300.0)), "after the end"); + assert!(over_member(&c, Point::new(0.0, 305.5)), "beside the line"); + assert!(!over_member(&c, Point::new(0.0, 306.5)), "out of reach"); + assert!(!over_member(&c, Point::new(-6.5, 300.0)), "out of reach before the start"); + } + + /// Dragging the middle object's widget past the last one moves it to the last place; dragging + /// the last one's beyond the start of the line moves it to the first place. Each drag is one + /// undo step, and the release chooses the object. + #[test] + fn dragging_a_select_move_widget_moves_its_object_to_the_nearest_place() { + let (d, g, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Down, 200.0, 300.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 280.0, 310.0)); + assert_eq!(a, vec![Action::Begin("Move Object on Path".into())], "{}: still nearest its own place", t.id()); + assert!(t.transforming() && t.cursor(&c, Point::new(280.0, 310.0), Mods::default()) == Cursor::Move); + let a = t.pointer(&c, &ev(PointerKind::Drag, 330.0, 300.0)); + assert!( + matches!(a.as_slice(), [Action::Preview(cmd, p)] if cmd == "object.objectsOnPath.moveMember" && *p == json!({ "group": g.0, "member": ids[1].0, "target": ids[2].0 })), + "{}: {a:?}", + t.id() + ); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 600.0, 300.0)).is_empty(), "{}: beyond the end, the last place", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 600.0, 300.0)), vec![Action::Commit, choose(ids[1])], "{}", t.id()); + assert!(!t.busy() && !t.transforming()); + // The last one, back before the start of the line. + t.pointer(&c, &ev(PointerKind::Down, 400.0, 300.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 250.0, 300.0)); + assert_eq!(previewed_target(&a), Some(ids[1].0), "{}: {a:?}", t.id()); + let a = t.pointer(&c, &ev(PointerKind::Drag, -80.0, 300.0)); + assert_eq!(previewed_target(&a), Some(ids[0].0), "{}: {a:?}", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, -80.0, 300.0)), vec![Action::Commit, choose(ids[2])], "{}", t.id()); + } + } + + /// A drag that comes back to its own place previews the object back there and keeps nothing on + /// release; Escape cancels a drag. + #[test] + fn a_select_move_drag_back_to_its_place_or_escaped_keeps_nothing() { + let (d, _, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 190.0, 300.0))), Some(ids[1].0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 20.0, 300.0))), Some(ids[0].0), "{}: back onto itself", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 20.0, 300.0)), vec![Action::Cancel, choose(ids[0])], "{}", t.id()); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 390.0, 300.0))).is_some()); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{}", t.id()); + assert!(!t.busy() && !t.transforming(), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 0.0, 300.0)).is_empty(), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)).is_empty(), "{}: the release keeps nothing", t.id()); + } + } + + /// Round a closed path the drag goes on across the first anchor: the first object, dragged back + /// past it, takes the last place. + #[test] + fn a_select_move_drag_goes_round_a_closed_path_across_its_first_anchor() { + // The square's outline is 400 pt round, from (100, 100) to the right and on clockwise: the + // four objects sit on its corners. + let (d, _, sel, ids) = members_doc(shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 100.0, 130.0))).is_none(), "nearer its own place"); + let a = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 180.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 100.0, 180.0)), vec![Action::Commit, choose(ids[0])]); + // Forward from the last one across the first anchor: the first place. + t.pointer(&c, &ev(PointerKind::Down, 100.0, 200.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 120.0)); + assert_eq!(previewed_target(&a), Some(ids[0].0), "{a:?}"); + } + + /// A hidden or locked object has no widget, and a drag does not take its place: the first + /// object, dragged onto the locked last one's place, stays at the nearest place it can take. + #[test] + fn a_locked_object_has_no_select_move_widget_and_keeps_its_place() { + let (mut d, _, sel, ids) = three_doc(); + d.node_mut(ids[2]).unwrap().locked = true; + assert_eq!(MemberWidget::all(&d, &sel, 1.0).iter().map(|w| w.member).collect::>(), ids[..2]); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 400.0, 300.0)); + assert!(t.pointer(&c, &ev(PointerKind::Up, 400.0, 300.0)).iter().all(|a| *a != choose(ids[2])), "no widget to press"); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 390.0, 300.0))), Some(ids[1].0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 390.0, 300.0)), vec![Action::Commit, choose(ids[0])]); + } + + /// Of widgets on the same point (no Space between the objects), a press takes the chosen + /// object's, else the first one's; between places as near, a drag keeps the place it is at. + #[test] + fn stacked_select_move_widgets_take_the_chosen_one_and_ties_keep_the_place() { + let (d, _, mut sel, ids) = members_doc(shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), 3, Some(0.0)); + let paint = paint(); + { + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[0])]); + // Every place is as far as the others: the drag stays where it is. + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 200.0, 300.0))).is_none()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 200.0, 300.0)), vec![Action::Cancel, choose(ids[0])]); + } + sel.on_path_member = Some(ids[2]); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[2])], "the chosen one"); + } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 1658346127..3af4a84ea7 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -47,7 +47,9 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; use crate::corners::{self, CornerDrag, over_widget}; use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path}; -use crate::objects_on_path::{MoveAllDrag, RotateAllDrag, SpaceDrag, over_move_all, over_rotate_all, over_space}; +use crate::objects_on_path::{ + MemberDrag, MoveAllDrag, RotateAllDrag, SpaceDrag, member_hover, over_member, over_move_all, over_rotate_all, over_space, +}; use crate::pathtype::{self, BracketDrag, over_bracket}; use crate::pie::{self, PieDrag}; use crate::rulerguide::GuideEdit; @@ -135,6 +137,8 @@ enum State { Space(SpaceDrag), /// Dragging an Objects on Path group's Move All widget. MoveAll(MoveAllDrag), + /// Pressing or dragging the Select/Move widget of an object of an Objects on Path group. + Member(MemberDrag), /// Move or rotate a live mirror axis. Mirror(crate::mirror::MirrorDrag), /// Drag a grid repeat's row, column or corner handle or a spacing slider. @@ -753,7 +757,7 @@ impl Tool for SelectionTool { /// E during a marquee drag switches the marquee between the objects it touches and only the /// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or - /// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space or Move All widget, the brackets of type on a path, a mirror + /// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space, Move All or Select/Move widget, the brackets of type on a path, a mirror /// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy /// is left, and the tool waits for the next press. With the pointer loaded with text, Escape /// drops the text. @@ -845,6 +849,7 @@ impl Tool for SelectionTool { State::RotateAll(d) => d.adjusting(), State::Space(d) => d.adjusting(), State::MoveAll(d) => d.adjusting(), + State::Member(d) => d.adjusting(), State::Bracket(b) => b.adjusting(), State::Mirror(d) => d.adjusting(), State::GridRepeat(d) => d.adjusting(), @@ -1042,6 +1047,15 @@ impl Tool for SelectionTool { self.state = State::Idle; d.finish() } + (PointerKind::Drag, State::Member(mut d)) => { + let out = d.drag(cx, p); + self.state = State::Member(d); + out + } + (PointerKind::Up, State::Member(d)) => { + self.state = State::Idle; + d.finish() + } (PointerKind::Drag, State::Bracket(mut b)) => { let out = b.drag(cx, p, m.cmd); self.state = State::Bracket(b); @@ -1110,6 +1124,9 @@ impl Tool for SelectionTool { State::Loaded { press: Some((start, cur)), .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), _ => {} } + if let (Some(p), State::Idle) = (self.bracket_hover, &self.state) { + o.extend(member_hover(cx, p)); + } o.extend(self.guides.iter().cloned()); o.extend(self.guide.overlays(cx)); if let (Some((q, label)), State::Idle, true) = (self.hover, &self.state, cx.smart_guides) { @@ -1158,7 +1175,7 @@ impl Tool for SelectionTool { State::Pie(_) => return Cursor::ArrowHollow, State::RotateAll(_) => return Cursor::Rotate, State::Space(_) => return Cursor::ArrowHollow, - State::MoveAll(_) => return Cursor::Move, + State::MoveAll(_) | State::Member(_) => return Cursor::Move, State::Bracket(_) => return Cursor::PathBracket, State::Loaded { .. } => return Cursor::LoadedText, _ => {} @@ -1184,7 +1201,7 @@ impl Tool for SelectionTool { if over_space(cx, p) { return Cursor::ArrowHollow; } - if over_move_all(cx, p) { + if over_move_all(cx, p) || over_member(cx, p) { return Cursor::Move; } if over_bracket(cx, p) { @@ -1536,7 +1553,7 @@ impl SelectionTool { self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; return Some(vec![]); } - // 1. Live Corners widgets, pie widgets, the Rotate All, Space and Move All widgets and type on a path's brackets, then the + // 1. Live Corners widgets, pie widgets, the Rotate All, Space, Move All and Select/Move widgets and type on a path's brackets, then the // bounding-box handles of the current selection. if let Some(c) = CornerDrag::hit(cx, ev, false) { self.state = State::Corner(c); @@ -1558,6 +1575,10 @@ impl SelectionTool { self.state = State::MoveAll(d); return Some(vec![]); } + if let Some(d) = MemberDrag::hit(cx, ev) { + self.state = State::Member(d); + return Some(vec![]); + } if let Some(b) = BracketDrag::hit(cx, ev) { self.state = State::Bracket(b); return Some(vec![]); diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 8ff6faaf79..88c573c5e4 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -238,14 +238,15 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: rotate_all_widget(p, xf, tool, &st.doc, &st.selection); } -/// The Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one Objects on Path -/// group selected). The Rotate All widget is joined by a line to the first object's attachment -/// point. It stays while dragged. +/// The Select/Move, Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one +/// Objects on Path group selected). The Rotate All widget is joined by a line to the first object's +/// attachment point. It stays while dragged. fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { use vectorcraft_tools::objects_on_path::{ROTATE_ALL_RADIUS_PX, RotateAllWidget}; if !matches!(tool, "selection" | "objectsOnPath") { return; } + member_widgets(p, xf, doc, selection); if let Some(w) = RotateAllWidget::of(doc, selection, xf.zoom) { let (on, c, color) = (xf.to_screen(w.pivot), xf.to_screen(w.at), c32(doc.layer_color(w.group))); p.line_segment([on, c], Stroke::new(1.0, color)); @@ -256,6 +257,23 @@ fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_d move_all_widget(p, xf, doc, selection); } +/// The Select/Move widgets: a ring on each object's attachment point, filled blue for the chosen +/// object. They are drawn under the other widgets, which a press takes first. +fn member_widgets(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { + use vectorcraft_tools::objects_on_path::{MEMBER_RADIUS_PX, MemberWidget}; + for w in MemberWidget::all(doc, selection, xf.zoom) { + let c = xf.to_screen(w.at); + if w.chosen { + p.circle(c, MEMBER_RADIUS_PX, MEMBER_CHOSEN, Stroke::new(1.0, Color32::WHITE)); + } else { + p.circle(c, MEMBER_RADIUS_PX, Color32::TRANSPARENT, Stroke::new(1.5, c32(doc.layer_color(w.group)))); + } + } +} + +/// The fill of the chosen object's Select/Move widget. +pub(super) const MEMBER_CHOSEN: Color32 = Color32::from_rgb(0x1f, 0x6f, 0xeb); + /// The Space widget (beside the Rotate All widget, with two or more objects on the path): a square /// joined by a line to the last object's attachment point. It stays while dragged. fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index e84d5cd076..6b9f5f1d22 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4898,6 +4898,38 @@ fn a_selected_objects_on_path_group_shows_its_move_all_widget_when_it_has_room() assert_eq!(widgets(&mut app), 1, "one object"); } +/// A selected Objects on Path group shows a Select/Move widget on each object's attachment point +/// with the Selection and Objects on Path tools, the chosen object's filled blue. +#[test] +fn a_selected_objects_on_path_group_shows_a_select_move_widget_per_object() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let line = id(app.session.execute("shape.line", &json!({"x1": 20, "y1": 200, "x2": 380, "y2": 200})).unwrap()); + let a = id(app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap()); + let b = id(app.session.execute("shape.rectangle", &json!({"x": 80, "y": 40, "width": 20, "height": 20})).unwrap()); + let c = id(app.session.execute("shape.rectangle", &json!({"x": 120, "y": 40, "width": 20, "height": 20})).unwrap()); + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + app.session.execute("object.objectsOnPath.attach", &json!({"path": line})).unwrap(); + let ctx = egui::Context::default(); + let r = vectorcraft_tools::objects_on_path::MEMBER_RADIUS_PX; + let widgets = |app: &mut VectorcraftApp, fill: Color32| { + shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(k) if k.radius == r && k.fill == fill)).count() + }; + let (ring, chosen) = (Color32::TRANSPARENT, super::overlays::MEMBER_CHOSEN); + app.select_tool("selection"); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (3, 0)); + app.run("object.objectsOnPath.selectMember", json!({"member": b})).unwrap(); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (2, 1), "the chosen one is filled"); + app.select_tool("objectsOnPath"); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (2, 1)); + app.select_tool("directSelection"); + assert_eq!(widgets(&mut app, ring) + widgets(&mut app, chosen), 0); + app.select_tool("selection"); + app.run("select.set", json!({"ids": [a]})).unwrap(); + assert_eq!(widgets(&mut app, ring) + widgets(&mut app, chosen), 0, "an object in the group, not the group"); +} + /// The shapes Type → Show Hidden Characters draws in one frame, seen through `xf`. fn hidden_chars(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) -> Vec { let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() }; diff --git a/docs/mcp.md b/docs/mcp.md index 288e86ae3e..53a2d03091 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8873,6 +8873,30 @@ value the open path is too short for sets the distance, the drag also sets `spac objects stay that far apart. Shift and Alt do not change the drag. With measurement labels on, the first object's distance along the path shows next to the pointer in the document's units. Escape cancels the drag. +The same tools show a Select/Move widget, a small ring, on each object's attachment point; the ring under the pointer +is highlighted. A click on one chooses its object: it sends `object.objectsOnPath.selectMember {member}`, which +selects the group alone with the object chosen, and the chosen object's ring is filled blue. Any other selection +change clears the choice. A click on the object's art away from the ring selects the group as before. Dragging a ring +along the path moves its +object to the place of the object whose attachment point is nearest the pointer along the path, and previews +`object.objectsOnPath.moveMember {group, member, target}`; the release keeps it as one undo step and chooses the +object. A drag that ends on the object's own place, and Escape, keep nothing. On an open path a pointer beyond an end +takes the place at that end; on a closed path the drag goes on round across the first anchor. Where two places are as +near, the drag stays at the place it is at. A hidden or locked object has no widget, and a drag does not take or +shift its place. Shift and Alt do not change the drag. The widgets for Rotate All, Space and +Move All take a press before a Select/Move widget, and of Select/Move widgets on the same point a press takes the +chosen one, else the first along the path. + +`object.objectsOnPath.moveMember {member, target, group?}` moves `member` to `target`'s place along the path: the +objects between them each move one place toward where `member` was, except hidden and locked objects, which keep +their places and are passed over. Both must be objects of the same Objects on Path +group (not its path), and neither they nor the group may be hidden or locked; `group`, when given, must be that +group. Only the +order along the path changes: Space, the start, Pivot, Rotate and the children's order in the Layers panel stay. A +`target` that is `member` changes nothing and records no undo step. It returns `{group, member, index}`, the object's +new place along the path counted from 0. `object.objectsOnPath.selectMember {member}` takes the same kind of `member` +and returns `{group, member}`. + ```json {"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"objectsOnPath","values":{"pivot":7,"rotate":30}}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.attach","params":{"path":30,"ids":[12,15,18]}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 351ade4468..4b3dd3bb7d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1289,9 +1289,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. The Blend panel's Create Spine menu (Auto, Always, Never; `object.blend.createSpine`) sets whether new blends get a spine, from the Blend tool, Make and `object.blend.make` alike ([Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html)). Auto and Always give every new blend a straight spine, as before; Never makes blends without one, which keep their steps on the straight lines between the keys, have Type and Ramp dimmed, refuse Reverse Spine and spine edits, release without a spine path, and get a spine from Replace Spine. The setting is kept with the preferences and leaves blends already made as they are; Auto as the starting value and keeping the setting across sessions are VectorCraft's own rules. Builds on #5177 by adambelis. Evidence: `tests::blendopts::{create_spine_sets_whether_new_blends_get_a_spine, a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release, whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one}` and `panels::tests_blend::create_spine_sets_what_new_blends_get_whatever_is_selected`. With the Blend tool, Escape drops the object clicked first and stops the next click adding to the blend just made, so the next click picks a new first object; the document and the selection stay as they are. Regression tests: `escape_drops_the_blend_tools_first_pick` in `meshblend.rs` and `tests/blendopts.rs`. - Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The Select/Move widget and Detach are missing. + Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Detach is missing. Objects on Path Space widget: with one Objects on Path group of two or more objects selected, the Selection and Objects on Path tools show a square handle beyond its last object; dragging it along the path, round its bends, sets the distance between the objects' attachment points in one undo step, the first object staying where it is, and with Alt (Option on macOS) the objects spread from both ends toward the center; the distance shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The group keeps the distance (`object.objectsOnPath.options {spacing, start}`) through Pivot and Rotate changes, path edits, copies, saving and reopening, and scales it with the group; by default (`"auto"`) the objects are spread evenly as before. Evidence: `objects_on_path::tests::{space_sets_the_distance_between_the_stops, space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object}` in `crates/doc`, `tests::objects_on_path::space_sets_the_distance_between_the_objects_in_one_undo_step` in `crates/engine`, `objects_on_path::tests::dragging_the_space_widget_spaces_the_objects_from_the_first_one` in `crates/tools`. Objects on Path Move All widget: with one Objects on Path group selected whose objects have room to move, the Selection and Objects on Path tools show a diamond below its first object; dragging it along the path, round its bends, moves every object along the path together in one undo step, keeping the distances between them (`object.objectsOnPath.options {start}`). On an open path the widget shows only when the distance from the first object to the last is shorter than the path, so not for objects spread evenly from end to end, and the objects stop at the path's ends; on a closed path they go on round across its first anchor. The first object's distance along the path shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Evidence: `objects_on_path::tests::{dragging_the_move_all_widget_moves_every_object_along_the_path, a_move_all_drag_on_a_closed_path_goes_round_across_the_first_anchor}` in `crates/tools`, `canvas::tests::a_selected_objects_on_path_group_shows_its_move_all_widget_when_it_has_room` in `crates/ui-egui`. + Objects on Path Select/Move widget: with one Objects on Path group selected, the Selection and Objects on Path tools show a small ring on each object's attachment point, highlighted under the pointer. A click on one chooses that object and fills its ring blue while the group stays selected (`object.objectsOnPath.selectMember`); a click on the object's art still selects the group. Dragging a ring along the path moves its object to the object's place nearest the pointer, and the objects between move one place toward where it was, in one undo step (`object.objectsOnPath.moveMember`); hidden and locked objects keep their places; only the order along the path changes, not the Layers panel's order or the Space. On an open path the drag stops at the ends, on a closed path it goes on round across the first anchor, and Escape cancels it ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Evidence: `objects_on_path::tests::moving_an_object_shifts_the_ones_between_it_and_its_new_place` in `crates/doc`, `tests::objects_on_path::move_member_takes_the_target_s_place_and_shifts_the_objects_between` in `crates/engine`, `objects_on_path::tests::{dragging_a_select_move_widget_moves_its_object_to_the_nearest_place, a_select_move_drag_goes_round_a_closed_path_across_its_first_anchor}` in `crates/tools`, `canvas::tests::a_selected_objects_on_path_group_shows_a_select_move_widget_per_object` in `crates/ui-egui`. Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. From 6a6a1baa28af9da0e9e2aee406c99c4bb4eb2b35 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 02:21:22 -0800 Subject: [PATCH 28/79] Color: the screen color sampler names its Wayland parent on its own thread Exporting the window for the portal waits for the compositor to answer. The sampler now does it on its worker thread, as the portal's save dialog does, so a slow compositor does not hold up the UI. --- apps/vectorcraft/src/linux_screen_color.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/vectorcraft/src/linux_screen_color.rs b/apps/vectorcraft/src/linux_screen_color.rs index 0a07f2053c..0f46af19d8 100644 --- a/apps/vectorcraft/src/linux_screen_color.rs +++ b/apps/vectorcraft/src/linux_screen_color.rs @@ -1,7 +1,7 @@ //! Linux: Sample Color from Screen (`vectorcraft_ui_egui::screen_color`) through the XDG desktop //! portal's color sampler ([`portal`]). A thread of its own asks the portal and waits for the //! person, so the UI keeps drawing; it sends the color to the UI and wakes the window. The dialog's -//! parent is the window, named on the UI thread as for the portal's save dialog (`portal_save`). +//! parent is the window, named on that thread as for the portal's save dialog (`portal_save`). mod portal; mod request; @@ -11,17 +11,17 @@ pub fn sampler(ui: egui::Context, parent: super::host_services::Parent) -> vecto Box::new(move || { let (tx, rx) = std::sync::mpsc::channel(); let (ui, window) = (ui.clone(), parent.clone()); - // The thread holds the window until the export that names it is dropped. - let (handle, export) = match window.as_deref() { - Some(window) => { - let (handle, export) = super::portal_save::exported_parent(window); - (handle, Some(export)) - } - None => (String::new(), None), - }; std::thread::Builder::new() .name("screen-color".into()) .spawn(move || { + // The thread names the window and holds it until the export that names it is dropped. + let (handle, export) = match window.as_deref() { + Some(window) => { + let (handle, export) = super::portal_save::exported_parent(window); + (handle, Some(export)) + } + None => (String::new(), None), + }; let picked = portal::connect(zbus::blocking::connection::Builder::session()).and_then(|conn| portal::pick(&conn, &handle)); drop(export); drop(window); From 71fcd584b03592a836afbd30a0a4b8a52ffa5a78 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 02:35:35 -0800 Subject: [PATCH 29/79] Cutting Tools: limits count hidden clip art, and joined art is not refused - Weed Box and Lines counts the outline points a nested clip group hides when it tests pieces for being inside a region, and counts the work of every clip group in the selection together, so a selection cannot run far past the limit. - Stencil Bridges refuses art when its measuring budget runs out only while an island is still unjoined, and a refusal for size or detail says so. --- .../engine/src/cmd/addons/cutting/stencil.rs | 7 ++- .../src/cmd/addons/cutting/stencil/tests.rs | 28 +++++++++++ .../src/cmd/addons/cutting/weed/geometry.rs | 34 +++++++++---- .../src/cmd/addons/cutting/weed/tests.rs | 49 +++++++++++++++++++ crates/pathops/src/addons/stencil.rs | 2 +- crates/pathops/src/addons/stencil/tests.rs | 31 ++++++++++++ docs/mcp.md | 5 +- 7 files changed, 143 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/addons/cutting/stencil.rs b/crates/engine/src/cmd/addons/cutting/stencil.rs index a8b4cfe591..dc415ce73d 100644 --- a/crates/engine/src/cmd/addons/cutting/stencil.rs +++ b/crates/engine/src/cmd/addons/cutting/stencil.rs @@ -89,7 +89,12 @@ fn bridges(s: &mut Session, p: &Value) -> Result { }; let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect(); let spec = BridgeSpec { width, count: count as usize, direction }; - let bridged = stencil_bridges(&refs, spec).map_err(|e| EngineError::Other(e.to_string()))?; + let bridged = stencil_bridges(&refs, spec).map_err(|e| match e { + vectorcraft_pathops::PathOpsError::TooComplex => { + EngineError::Other("Stencil Bridges: the selected art is too large or too detailed to bridge".into()) + } + e => EngineError::Other(e.to_string()), + })?; (bridged, style_of(&top), roots) }; let summary = |id: Option, original: Option| { diff --git a/crates/engine/src/cmd/addons/cutting/stencil/tests.rs b/crates/engine/src/cmd/addons/cutting/stencil/tests.rs index ad072ca13d..b21f0fef5c 100644 --- a/crates/engine/src/cmd/addons/cutting/stencil/tests.rs +++ b/crates/engine/src/cmd/addons/cutting/stencil/tests.rs @@ -277,6 +277,34 @@ fn bad_params_are_refused() { assert!(s.execute("addon.cutting.bridges", &json!({"width": 4})).is_ok(), "good params run"); } +#[test] +fn art_too_detailed_to_bridge_is_refused_with_a_message_that_says_so() { + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("dense", |d, sel| { + // More anchors than Stencil Bridges measures. + let mut zigzag = kurbo::BezPath::new(); + zigzag.move_to((100.0, 100.0)); + for i in 1..=vectorcraft_pathops::addons::stencil::MAX_STENCIL_SEGMENTS { + zigzag.line_to((100.0 + i as f64 * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + zigzag.close_path(); + let huge = vectorcraft_geom::PathData::from_bezpath(&zigzag); + let n = vectorcraft_doc::Node::path(d.alloc_id(), huge, vectorcraft_doc::Appearance::default()); + let id = n.id; + d.insert(parent, usize::MAX, n)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let steps = undo_steps(&s); + let e = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap_err().to_string(); + assert!(e.contains("Stencil Bridges: the selected art is too large or too detailed to bridge"), "{e}"); + assert_eq!(undo_steps(&s), steps); +} + #[test] fn selected_objects_without_outlines_stay_where_they_are() { for keep in [true, false] { diff --git a/crates/engine/src/cmd/addons/cutting/weed/geometry.rs b/crates/engine/src/cmd/addons/cutting/weed/geometry.rs index d9f612114b..cf7df51d80 100644 --- a/crates/engine/src/cmd/addons/cutting/weed/geometry.rs +++ b/crates/engine/src/cmd/addons/cutting/weed/geometry.rs @@ -511,28 +511,43 @@ impl Region { /// The pieces of outline the region shows: every outline of a shape and of a group's members, /// and in a clip group the members' outlines inside the clipping path and the clipping path's - /// outline where the members' area or outlines are. Errs past [`MAX_PAIRS`]. + /// outline where the members' area or outlines are. Errs past [`MAX_PAIRS`], counted over + /// every clip group in the region together. pub(super) fn outline(&self) -> Result, String> { + self.outline_counted(&mut 0) + } + + /// [`Region::outline`], adding the measure's work to `work`. + fn outline_counted(&self, work: &mut usize) -> Result, String> { match self { Region::Shape { polys, .. } => Ok(polys.iter().flat_map(Poly::segments).collect()), Region::Union(parts) => { let mut out = vec![]; for r in parts { - out.extend(r.outline()?); + out.extend(r.outline_counted(work)?); } Ok(out) } Region::Clip { clip, content } => { // Each side's area is bounded by the outlines it shows, so the pieces are cut and // tested against those, never against art a nested clip hides. - let (clip_shown, content_shown) = (clip.outline()?, content.outline()?); - let mut work = 0; - let mut out = within(&content_shown, clip, &clip_shown, &mut work)?; - out.extend(within(&clip_shown, content, &content_shown, &mut work)?); + let (clip_shown, content_shown) = (clip.outline_counted(work)?, content.outline_counted(work)?); + let mut out = within(&content_shown, clip, &clip_shown, work)?; + out.extend(within(&clip_shown, content, &content_shown, work)?); Ok(out) } } } + + /// The points of the region's closed outlines, those a nested clip hides included: what one + /// [`Region::spans`] looks at, at most. + fn closed_points(&self) -> usize { + match self { + Region::Shape { polys, .. } => polys.iter().filter(|p| p.closed).map(|p| p.points.len()).sum(), + Region::Union(parts) => parts.iter().map(Region::closed_points).sum(), + Region::Clip { clip, content } => clip.closed_points().saturating_add(content.closed_points()), + } + } } fn too_complex_clip() -> String { @@ -541,12 +556,13 @@ fn too_complex_clip() -> String { /// The pieces of `segments` inside `region` (or on its outlines), cut where they cross /// `region_segments`, the outlines the region shows. Each segment, and each piece of one tested -/// for being inside, adds the region's segment count to `work`, and the measure errs past -/// [`MAX_PAIRS`]. +/// for being inside, adds the region's closed outline points (hidden ones included) and its +/// shown segment count to `work`, and the measure errs past [`MAX_PAIRS`]. fn within(segments: &[Segment], region: &Region, region_segments: &[Segment], work: &mut usize) -> Result, String> { let mut out = vec![]; + let cost = region.closed_points().saturating_add(region_segments.len()).max(1); let charge = |work: &mut usize| { - *work = work.saturating_add(region_segments.len().max(1)); + *work = work.saturating_add(cost); if *work > MAX_PAIRS { Err(too_complex_clip()) } else { Ok(()) } }; for &(a, b) in segments { diff --git a/crates/engine/src/cmd/addons/cutting/weed/tests.rs b/crates/engine/src/cmd/addons/cutting/weed/tests.rs index 280ae909bb..76f5f656c6 100644 --- a/crates/engine/src/cmd/addons/cutting/weed/tests.rs +++ b/crates/engine/src/cmd/addons/cutting/weed/tests.rs @@ -356,6 +356,55 @@ fn art_a_nested_clip_hides_adds_no_outline() { assert_eq!(weed_box(&g), Some(Rect::new(100.0, 100.0, 150.0, 140.0)), "only the shown rectangle is measured"); } +#[test] +fn art_a_nested_clip_hides_counts_toward_the_clip_limit() { + // A clip group, clipping path A of 20,000 segments, holding a clip group whose small clipping + // path B shows a rectangle and hides a closed zigzag C of 20,000 points across every row of A. + // The inner group shows four outline pieces, but testing each piece of A for being inside it + // looks at all of C. + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let id = s + .edit("nested clips", |d, sel| { + let mut path = |p: BezPath| Arc::new(Node::path(d.alloc_id(), PathData::from_bezpath(&p), Appearance::default())); + let mut a = BezPath::new(); + a.move_to((0.0, 0.0)); + for i in 1..=20_000 { + a.line_to((f64::from(i) * 0.02, if i % 2 == 0 { 0.0 } else { 0.5 })); + } + a.line_to((400.0, 300.0)); + a.line_to((0.0, 300.0)); + a.close_path(); + let mut c = BezPath::new(); + c.move_to((300.0, 0.0)); + for i in 1..=20_000 { + c.line_to((300.0 + f64::from(i) * 0.002, if i % 2 == 0 { 0.0 } else { 300.0 })); + } + c.close_path(); + let b = path(shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)).to_bezpath()); + let shown = path(shapes::rectangle(Rect::new(110.0, 110.0, 140.0, 130.0)).to_bezpath()); + let (a, c) = (path(a), path(c)); + let clipped = |mut g: Node| { + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + g + }; + let inner = Arc::new(clipped(Node::group(d.alloc_id(), vec![b, shown, c]))); + let outer = clipped(Node::group(d.alloc_id(), vec![a, inner])); + let id = outer.id; + d.insert(parent, usize::MAX, outer)?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + select(&mut s, &[id]); + let n = s.doc().unwrap().doc.node_count(); + let e = s.execute("addon.cutting.weed", &json!({"margin": 10, "count": 0})).unwrap_err(); + assert!(e.to_string().contains("too many outline segments"), "{e}"); + assert_eq!(s.doc().unwrap().doc.node_count(), n); +} + #[test] fn art_selected_inside_a_clip_group_is_measured_by_what_shows() { // The rectangle runs far outside the clipping path; selected on its own, it still shows only diff --git a/crates/pathops/src/addons/stencil.rs b/crates/pathops/src/addons/stencil.rs index 437433e40c..a5412fa328 100644 --- a/crates/pathops/src/addons/stencil.rs +++ b/crates/pathops/src/addons/stencil.rs @@ -146,7 +146,7 @@ fn choose(contours: &[Contour], spec: BridgeSpec, work: usize) -> Result<(Vec
0 { return Err(PathOpsError::TooComplex); } picker.spread(spec.count); diff --git a/crates/pathops/src/addons/stencil/tests.rs b/crates/pathops/src/addons/stencil/tests.rs index 48d2f8148e..93ea92cdde 100644 --- a/crates/pathops/src/addons/stencil/tests.rs +++ b/crates/pathops/src/addons/stencil/tests.rs @@ -210,6 +210,37 @@ fn bridges_found_before_the_budget_runs_out_are_kept() { assert!(matches!(choose(&found, spec(4.0, 1, BridgeDirection::Vertical), least - 1), Err(PathOpsError::TooComplex))); } +#[test] +fn joined_art_is_kept_when_lines_in_one_direction_run_out() { + // A ring whose thin left bar has edges of many short zigzag segments: lines across it in the + // horizontal direction look at many more segments than vertical lines across the top bar. + let zigzag = |x: f64, from: f64, to: f64, out: &mut Vec| { + let n = ((to - from).abs() / 0.05) as usize; + out.extend((1..n).map(|i| Point::new(x + if i % 2 == 1 { 0.02 } else { 0.0 }, from + (to - from) * i as f64 / n as f64))); + }; + let mut outer = vec![Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 100.0), Point::new(0.0, 100.0)]; + zigzag(0.0, 100.0, 0.0, &mut outer); + let mut hole = vec![Point::new(10.0, 20.0), Point::new(170.0, 20.0), Point::new(170.0, 80.0), Point::new(10.0, 80.0)]; + zigzag(9.98, 80.0, 20.0, &mut hole); + let art = PathData::new(vec![SubPath::polyline(&outer, true), SubPath::polyline(&hole, true)]); + let found = contours(&art, 4.0).unwrap(); + let shortest = spec(4.0, 1, BridgeDirection::Shortest); + let views = (0..180).step_by(SHORTEST_STEP_DEG).count(); + // A share of the work that runs out in the horizontal direction while another direction joins + // the island. + let share = (1..5000) + .find(|&budget| { + let all: Vec = + (0..180).step_by(SHORTEST_STEP_DEG).map(|d| View::new(&found, (d as f64).to_radians(), 4.0, budget).unwrap()).collect(); + let mut picker = Picker::new(&found, &all, 4.0); + picker.connect(); + all.iter().any(View::exhausted) && picker.unjoined() == 0 + }) + .expect("a share of the work that runs out with the island joined"); + let (bridges, unjoined) = choose(&found, shortest, share * views).unwrap(); + assert_eq!((bridges.len(), unjoined), (1, 0)); +} + #[test] fn curves_are_followed_within_a_twentieth_of_the_width() { let circle = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.001)); diff --git a/docs/mcp.md b/docs/mcp.md index 46f79de9e0..6195428c7d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9270,8 +9270,9 @@ The result is one group named "Weeding" (the box is its member "Weed Box"), adde selected, as one undo step. While a clip group, or a group inside one, is edited in isolation, the group goes on top of the container that holds the outermost clip group around it. With nothing selected, or with a selection that has no outlines, the command fails. It also fails when the art's outlines, followed in straight segments, have more than -200000 points; when groups, symbol instances and live objects are nested more than 128 levels deep; when measuring a -clip group's outlines against each other takes more than 40000000 tests of one segment against another; when, for the +200000 points; when groups, symbol instances and live objects are nested more than 128 levels deep; when measuring the +clip groups' outlines against each other takes more than 40000000 tests of one segment or outline point against another, +counted over every clip group in the selection together and including the outlines a nested clip group hides; when, for the lines one way, more than 10000000 pairs of a line and an outline segment within `gap` of it are found, or closed outline segments are checked for crossings with the lines more than 10000000 times; when the lines would be more than 100000 pieces; and when no line fits outside the art while `box` is false. Result: `{id, box, lines}`: the group's id, From 63eacbd94a40e7443f43df776a119a9853487413 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:04:52 +0430 Subject: [PATCH 30/79] Fix glyph browser reset, variation and insertion behavior --- crates/text/src/fontdb/alternates.rs | 1 - crates/text/src/test_fonts.rs | 70 +++++++++++++++++++++ crates/ui-egui/src/panels/glyphs.rs | 91 +++++++++++++++++++++++++++- docs/target-app-parity.md | 3 +- docs/type-parity.md | 3 +- 5 files changed, 163 insertions(+), 5 deletions(-) diff --git a/crates/text/src/fontdb/alternates.rs b/crates/text/src/fontdb/alternates.rs index c39067f0b6..4a374755ef 100644 --- a/crates/text/src/fontdb/alternates.rs +++ b/crates/text/src/fontdb/alternates.rs @@ -74,7 +74,6 @@ impl FontFace { pub fn is_unlisted_alternate(&self, c: char, gid: u32) -> bool { self.unencoded_glyphs().binary_search(&gid).is_ok() && self.feature_reaching(gid).is_none() - && self.glyph_origin(gid).is_none() && self.name_owner(gid).is_none_or(|owner| owner == c) } diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 601a67e064..e9bb2917ab 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -128,6 +128,41 @@ pub fn variable_font() -> Option> { Some(builder.build()) } +/// The family of [`unlisted_variable_font`]. +pub const UNLISTED_VARIABLE_FAMILY: &str = "Unlisted Varitest Sans"; + +/// A variable face with no GSUB: `a.a` is an unencoded alternate named for `a`, but the Glyphs +/// panel has no listed alternates. The weight axis comes from [`variable_font`]. +pub fn unlisted_variable_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + + let bytes = variable_font()?; + let font = skrifa::FontRef::new(&bytes).ok()?; + let mappings = [' ', 'a', 'l'] + .into_iter() + .map(|c| Some((c, write_fonts::types::GlyphId::new(font.charmap().map(c)?.to_u32())))) + .collect::>>()?; + let cmap = Cmap::from_mappings(mappings).ok()?; + let name = name_table(&[ + (1, UNLISTED_VARIABLE_FAMILY), + (2, "Regular"), + (4, "Unlisted Varitest Sans Regular"), + (5, "Version 1.000"), + (6, "UnlistedVaritestSans-Regular"), + (256, "Weight"), + ])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + builder.add_table(&cmap).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if tag != Tag::new(b"GSUB") && !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`two_axis_font`]. pub const TWO_AXIS_FAMILY: &str = "Biaxtest Sans"; @@ -920,6 +955,41 @@ pub fn joining_alternates_font() -> Option> { Some(builder.build()) } +/// The family of [`aliased_joining_font`]. +pub const ALIASED_JOINING_FAMILY: &str = "Alias Jointest Sans"; + +/// [`joining_alternates_font`] with Latin `B` and the Arabic initial beh presentation form +/// U+FE91 both mapped to beh's initial glyph. Only the presentation form should insert beh. +pub fn aliased_joining_font() -> Option> { + use write_fonts::tables::cmap::Cmap; + + let bytes = joining_alternates_font()?; + let font = skrifa::FontRef::new(&bytes).ok()?; + let source = skrifa::FontRef::new(SOURCE_SANS).ok()?; + let initial = write_fonts::types::GlyphId::new(source.charmap().map('B')?.to_u32()); + let mut mappings = + font.charmap().mappings().filter_map(|(c, g)| Some((char::from_u32(c)?, write_fonts::types::GlyphId::new(g.to_u32())))).collect::>(); + mappings.extend([('B', initial), ('\u{FE91}', initial)]); + let cmap = Cmap::from_mappings(mappings).ok()?; + let name = name_table(&[ + (1, ALIASED_JOINING_FAMILY), + (2, "Regular"), + (4, "Alias Jointest Sans Regular"), + (5, "Version 1.000"), + (6, "AliasJointestSans-Regular"), + ])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + builder.add_table(&cmap).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`ligature_caret_font`]. pub const LIGATURE_CARET_FAMILY: &str = "Carettest Sans"; diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index ba1f1c0f7d..3ac6ac5800 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -241,7 +241,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { font_row(app, ui, &family, &style, w); return; } - let Some(face) = db.face(&family, &style) else { + // The same family and style can have a different face id at the selection's version or axes. + // Use that instance for both the previews and Fit's applicability check. + let face = selected_target(app) + .filter(|t| t.style.font_family.eq_ignore_ascii_case(&family) && t.style.font_style.eq_ignore_ascii_case(&style)) + .and_then(|t| db.style_face(t.style)) + .or_else(|| db.face(&family, &style)); + let Some(face) = face else { ui.label(egui::RichText::new(tl!("No fonts are available.")).color(t.text_dim)); return; }; @@ -447,7 +453,7 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) { let (features, has_choice) = (target.style.features.clone(), target.style.glyph_alternate.is_some()); let found = alternates_found(app, ui, id, byte, face.id()); let font = format!("{} {}", face.family, face.style); - let Some(found) = found.filter(|f| !f.alternates.is_empty()) else { + let Some(found) = found.filter(|f| has_choice || !f.alternates.is_empty()) else { note(ui, tl!("No alternates for the selected character.")); ui.label(egui::RichText::new(font).size(11.0).color(t.text_dim)); return; @@ -679,6 +685,9 @@ fn glyph_label(face: &vectorcraft_text::FontFace, gid: u32) -> String { /// them; the letter takes the form its place in the word asks for, so the word stays joined. /// `None` when `c` is not such a form in this font. fn base_letter(face: &vectorcraft_text::FontFace, c: char, gid: u32) -> Option { + if !matches!(c, '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}') { + return None; + } let origin = face.glyph_origin(gid)?; origin.position()?; (origin.ch != c).then_some(origin.ch) @@ -1567,4 +1576,82 @@ mod tests { assert_eq!(glyph_ids(&app, &id), typed, "shaped as the word typed with beh"); assert!(app.ui.status.starts_with("Inserted ب (U+0628)"), "{}", app.ui.status); } + + #[test] + fn an_unlisted_choice_without_gsub_can_be_reset_from_the_default_cell() { + use vectorcraft_text::test_fonts::{UNLISTED_VARIABLE_FAMILY, unlisted_variable_font}; + let db = FontDb::global(); + db.add_font(unlisted_variable_font().expect("the test font builds")); + let face = db.face(UNLISTED_VARIABLE_FAMILY, "Regular").expect("the test face"); + let glyph = gid_named(&face, "a.a"); + let (mut app, id) = editing("a"); + app.run("text.setRangeStyle", json!({"id": id, "font": UNLISTED_VARIABLE_FAMILY})).unwrap(); + select(&mut app, 0, 1); + let plain = glyph_ids(&app, &id); + app.run("text.setGlyphAlternate", json!({"id": id, "start": 0, "gid": glyph})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [glyph]); + let ctx = egui::Context::default(); + choose(&ctx, &mut app, "Alternates for Current Selection"); + let cells = squares(&shapes(&ctx, &mut app, vec![])); + assert_eq!(cells.len(), 1, "the default remains available without any GSUB alternates"); + let undo = app.session.active().unwrap().history.undo.len(); + double_click(&ctx, &mut app, cells[0].center()); + assert_eq!(glyph_ids(&app, &id), plain); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(glyph_ids(&app, &id), [glyph]); + } + + #[test] + fn entire_font_applies_an_unlisted_glyph_at_the_selections_variable_axes() { + use vectorcraft_text::test_fonts::{UNLISTED_VARIABLE_FAMILY, unlisted_variable_font}; + let db = FontDb::global(); + db.add_font(unlisted_variable_font().expect("the test font builds")); + let base = db.face(UNLISTED_VARIABLE_FAMILY, "Regular").expect("the test face"); + let glyph = gid_named(&base, "a.a"); + let (mut app, id) = editing("a"); + app.run("text.setRangeStyle", json!({"id": id, "font": UNLISTED_VARIABLE_FAMILY, "fontAxes": {"wght": 650}})).unwrap(); + select(&mut app, 0, 1); + let target = selected_target(&app).expect("the selected character"); + let face = db.style_face(target.style).expect("the variable instance"); + assert_ne!(face.id(), base.id(), "a non-default setting has its own face id"); + let ctx = egui::Context::default(); + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count(); + let at = chars + face.unencoded_glyphs().iter().position(|g| *g == glyph).expect("the unencoded glyph"); + let cells = inks(&tall_shapes(&ctx, &mut app)); + assert_eq!(cells[at].1, Tokens::get(&ctx).text_strong, "the instance's alternate is enabled"); + double_click_in(&ctx, &mut app, cells[at].0.center(), TALL); + assert_eq!(glyph_ids(&app, &id), [glyph]); + assert_eq!(selected_target(&app).unwrap().style.font_axes["wght"], 650.0); + app.run("edit.undo", json!({})).unwrap(); + assert_ne!(glyph_ids(&app, &id), [glyph]); + } + + #[test] + fn encoded_characters_normalize_only_arabic_presentation_forms() { + use vectorcraft_text::test_fonts::{ALIASED_JOINING_FAMILY, aliased_joining_font}; + let db = FontDb::global(); + db.add_font(aliased_joining_font().expect("the test font builds")); + let face = db.face(ALIASED_JOINING_FAMILY, "Regular").expect("the test face"); + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).collect::>(); + for (c, expected) in [('B', "B"), ('\u{FE91}', "\u{0628}")] { + let (mut app, id) = editing("a"); + app.run("text.setRangeStyle", json!({"id": id, "font": ALIASED_JOINING_FAMILY})).unwrap(); + select(&mut app, 0, 1); + let at = chars.iter().position(|(ch, _)| *ch == c).expect("an encoded character"); + let origin = face.glyph_origin(chars[at].1).expect("the shared positional glyph"); + assert_eq!(origin.ch, '\u{0628}'); + assert!(origin.position().is_some()); + let ctx = egui::Context::default(); + let cells = inks(&tall_shapes(&ctx, &mut app)); + double_click_in(&ctx, &mut app, cells[at].0.center(), TALL); + let state = app.session.active().unwrap(); + let NodeKind::Text(text) = &state.doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), expected, "the encoded {c} cell inserts the intended character"); + app.run("edit.undo", json!({})).unwrap(); + let state = app.session.active().unwrap(); + let NodeKind::Text(text) = &state.doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") }; + assert_eq!(text.plain_text(), "a"); + } + } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f5120b6e4c..6a943b83ce 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2,7 +2,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** 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) > -> 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). Scores and estimates unchanged. +> 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. > > Live Paint Bucket update (2026-11-12): one drag fills the faces crossed in the same group (#6373). > @@ -2070,6 +2070,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Glyphs review (#6084): Entire Font uses the selection's version and variable axes for previews and alternate applicability. The default cell can reset an unlisted glyph choice when GSUB lists no alternates. Encoded Latin and other ordinary characters insert unchanged even if their glyph also has Arabic positional provenance; Arabic presentation forms retain base-letter insertion. Remove a redundant charmap scan from unlisted-glyph checks. Scores and estimates unchanged. | | 2026-11-09 | minor | Glyphs review (#6084): positional alternates with no supported feature are accepted as glyph choices in both panel views; Entire Font's unlisted-glyph and name-ownership behavior is retained. OpenType style options keep only a valid last on-value when enabling a set. Contradictory old glyph-choice evidence corrected; scores and estimates unchanged. | | 2026-11-07 | minor | Type core: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to the selected letter alone, so an Arabic word stays joined (`salt=2`; the style commands take `tag=N`), and Show › Entire Font lists the glyphs no character maps (#5514; [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html)); evidence added; scores unchanged. | | 2026-11-12 | minor | Type: kerning at the Type tool's caret at the start of a line (the text's, a paragraph's, or after a forced line break) sets the space before the character after it (`kerningBefore`), which moves it and the rest of its line; it did nothing at the text's start and kerned the invisible line break at a paragraph's (#5111). Scores and estimates unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index f4750fca3f..7917c0cf3d 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -2,7 +2,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (kerning at the Type tool's caret at the start of a line sets the space before the character after it, #5111; previously minor: type's strokes in an envelope paint along its bent glyph outlines at their own weight, Distort Appearance on or off, as users' `.ai` files draw them, #6644; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; 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: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) -Glyphs review (#6084): a positional variant no supported feature draws is accepted as a glyph choice. Scores and estimates unchanged. +Glyphs review (#6084): a positional variant no supported feature draws is accepted as a glyph choice. Entire Font respects the selection's variable axes and font version; the default cell can reset an unlisted choice without GSUB alternates. Encoded characters insert unchanged except Arabic presentation forms with a joining base letter. Scores and estimates unchanged. Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -84,6 +84,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Glyphs review (#6084): Entire Font resolves the selected font's version and variable axes; the default cell remains available to reset an unlisted choice with no listed alternates. Character insertion normalizes only Arabic presentation forms, preserving ordinary characters whose glyphs share Arabic substitution provenance. Add deterministic panel apply/reset/Undo regressions without OS-font dependencies. Scores and estimates unchanged. | | 2026-11-09 | minor | Glyphs review (#6084): the engine accepts a positional alternate as a glyph choice when no supported feature draws it, not only an isolated character's alternates; both panel views exercise apply and Undo. OpenType style options preserve only a valid last on-value when enabling a stylistic set. Scores and estimates unchanged. | | 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, applies the ones that are the selected letter's alternates, or that no feature reaches and the font's names do not give to another letter, and dims, with the reason, those that are not. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. | | 2026-11-11 | minor | Kerning at the start of a line (#5111): at the Type tool's caret before the first character of the text, of a paragraph or after a forced line break, the Kerning field, Alt+arrows and Reset Tracking and Kerning set the space before the character after the caret (`CharStyle::kerning_before`, `kerningBefore` in `text.setRangeStyle`), which moves it and the rest of its line, in horizontal, right-to-left and vertical type and in SVG export. They did nothing at the text's start and kerned the invisible line break at a paragraph's. Scores and estimates unchanged. | From 0857a6ccbad4730f280aea0309550b975da50fb1 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:22:10 +0430 Subject: [PATCH 31/79] Pin normalization regression to its aliased glyph font --- crates/ui-egui/src/panels/glyphs.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 3ac6ac5800..7126c1b506 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -1643,6 +1643,7 @@ mod tests { assert_eq!(origin.ch, '\u{0628}'); assert!(origin.position().is_some()); let ctx = egui::Context::default(); + set_pstate(&ctx, "gl-font", Some((ALIASED_JOINING_FAMILY.to_string(), "Regular".to_string()))); let cells = inks(&tall_shapes(&ctx, &mut app)); double_click_in(&ctx, &mut app, cells[at].0.center(), TALL); let state = app.session.active().unwrap(); From 59bc3677fe251ca0594cfa46f78afa815b35452b Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:26:48 +0430 Subject: [PATCH 32/79] Fix macOS temporary-path expectations in engine tests Canonicalize test roots before path comparisons and case-variant script probes. Production access checks are unchanged.\n\nFixes #6860 --- crates/engine/src/cmd/fileio/tests_screens.rs | 2 +- crates/engine/src/tests/script_io.rs | 2 +- docs/development.md | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 86ee164998..0b529c87c6 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -730,7 +730,7 @@ fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_ let settings = json!({"formats": [{"format": "png"}], "folder": parent}); s.execute("document.exportPresets.save", &json!({"name": "Parent", "settings": settings})).unwrap(); let e = confine(confined.as_ref(), || s.execute("document.exportForScreens", &json!({"exportPreset": "Parent"}))).unwrap_err().to_string(); - let root = inside.display().to_string(); + let root = crate::cmd::findfiles::plain(inside.canonicalize().unwrap()).display().to_string(); let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned(); assert!(e.contains(&format!("root {root}: {name}")), "{e}"); } diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs index d8c5d1d2e4..667635356c 100644 --- a/crates/engine/src/tests/script_io.rs +++ b/crates/engine/src/tests/script_io.rs @@ -230,7 +230,7 @@ fn script_file_link_escape_refused() { /// not matter, and outside it where case matters, whether or not the file is there. #[test] fn script_file_case_variant_folder() { - let base = folder("case"); + let base = folder("case").canonicalize().unwrap(); write(&base.join("main/own.txt"), b"own"); let upper = base.join("MAIN"); let source = |name: &str| { diff --git a/docs/development.md b/docs/development.md index 2362fc6f1a..07c0f65a77 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,5 +1,6 @@ # Development +> **Last updated:** 2026-11-12 · **Change:** Tests canonicalize temporary roots before comparing preset refusal paths or probing a case-variant script folder (#6860); production access checks unchanged. > **Last updated:** 2026-11-06 · **Change:** Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending. > **Last updated:** 2026-11-01 · **Change:** Linux: Automatic Linux Display Server opens the window through XWayland when a pen is attached on a Wayland session, so the pen draws (#4967); `VECTORCRAFT_NATIVE_WAYLAND=1` keeps Wayland. > **Last updated:** 2026-10-31 · **Change:** files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance`. @@ -508,6 +509,7 @@ Scores and hour estimates are unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Tests (#6860): compare preset refusal roots after canonicalization and Windows display normalization; canonicalize the script fixture base before constructing `MAIN`. This keeps macOS `/var` → `/private/var` aliases from masking the case-sensitivity checks; no production-code change. | | 2026-11-06 | minor | Localisation: Hindi catalog, zero/one plurals, desktop and web font requirements; AI/MT draft, native review pending. | | 2026-10-31 | minor | Files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance` opts out. | | 2026-10-29 | minor | Linux clipboard: core Wayland protocol text fallback where arboard has neither data-control nor X11 (the Flatpak on GNOME).; its reads wait 2 s at most. | From 4c0c6179c9c851edf9e11c2cf7270b51927c3b78 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:22:47 +0430 Subject: [PATCH 33/79] Fix contextual glyph face, cluster boundaries and insertion status --- crates/engine/src/cmd/textedit.rs | 17 +++- crates/engine/src/tests/textedit.rs | 83 ++++++++++++++++ crates/text/src/harakat.rs | 14 +-- crates/text/src/shape.rs | 121 +++++++++++++++++++---- crates/text/src/test_fonts.rs | 41 ++++++++ crates/text/src/tests/harakat.rs | 49 +++++++-- crates/text/src/tests/ligature_carets.rs | 59 +++++++++++ crates/ui-egui/src/panels/glyphs.rs | 50 ++++++++-- docs/type-parity.md | 3 +- 9 files changed, 390 insertions(+), 47 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index fd563bab9c..891e518a33 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1284,7 +1284,9 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { }; let t = text_ref(s, id)?; let (ch, bytes, face) = glyph_target(&t, p, C)?; - let default = face.glyph_for(ch); + let db = vectorcraft_text::FontDb::global(); + let context = std::cell::OnceCell::new(); + let contextual = || context.get_or_init(|| vectorcraft_text::alternates_in_context(db, &t, bytes.start)); let holder = t .runs .iter() @@ -1305,6 +1307,16 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { let mut probe = t.clone(); edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None); let probe_lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe); + // Choices belong to the supplying face, including fallback for a missing grapheme. When the + // character overflows, the contextual probe finds that face in an unconstrained frame. + let face = probe_lay + .glyphs + .iter() + .find(|g| g.byte <= bytes.start && bytes.start < g.byte.saturating_add(g.len)) + .and_then(|g| db.face_by_id(g.font_id)) + .or_else(|| contextual().as_ref().and_then(|f| db.face_by_id(f.font_id))) + .unwrap_or(face); + let default = face.glyph_for(ch); let unchosen = holder .as_ref() .filter(|_| !styled) @@ -1316,8 +1328,7 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { if g == default || face.alternates(ch).iter().any(|a| a.gid == g) || face.is_unlisted_alternate(ch, g) - || vectorcraft_text::alternates_in_context(vectorcraft_text::FontDb::global(), &t, bytes.start) - .is_some_and(|f| f.font_id == face.id() && f.alternates.iter().any(|a| a.gid == g)) => + || contextual().as_ref().is_some_and(|f| f.font_id == face.id() && f.alternates.iter().any(|a| a.gid == g)) => { Some(face.choice(ch, g)) } diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index de9eafedff..46d41adaea 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -3641,6 +3641,89 @@ fn an_overflowing_grapheme_keeps_the_styles_of_its_characters() { assert_eq!(r["gid"], json!(g('b')), "the overflowing Ka is shaped apart from the nukta of another size"); } +/// A contextual alternate with no feature must name the fallback face that supplies the glyph, +/// including when the character is outside its frame. Neither another face's glyph nor an +/// out-of-range id becomes a choice. +#[test] +fn a_contextual_fallback_glyph_choice_draws_through_undo_and_reopen() { + use vectorcraft_text::test_fonts::{ + CONTEXTUAL_FALLBACK_CHAR as CH, CONTEXTUAL_FALLBACK_FAMILY, alternates_family, alternates_font, contextual_fallback_font, source_sans_gid, + }; + let _fonts = crate::adding_fonts(); + let db = vectorcraft_text::FontDb::global(); + db.add_font(alternates_font(true).expect("the primary test font builds")); + db.add_font(contextual_fallback_font().expect("the fallback test font builds")); + let primary = db.face(alternates_family(true), "Regular").expect("the primary face"); + let fallback = db.face(CONTEXTUAL_FALLBACK_FAMILY, "Regular").expect("the supplying face"); + let g = |c| u32::from(source_sans_gid(c).expect("a Source Sans glyph")); + let (default, alternate) = (g('m'), g('d')); + assert_eq!(primary.glyph_for(CH), 0); + assert_eq!(db.face_covering(CH).unwrap().id(), fallback.id(), "only the fixture supplies the private-use character"); + assert!(!fallback.alternates(CH).iter().any(|a| a.gid == alternate), "the alternate belongs to its shaped, not nominal, glyph"); + assert!(!fallback.is_unlisted_alternate(CH, alternate), "the contextual validation path is required"); + let text = format!("a{CH}a"); + let start = 1; + let end = start + CH.len_utf8(); + let drawn = |t: &TextObject| { + let mut open = t.clone(); + open.kind = TextKind::Point; + vectorcraft_text::layout(db, &open).glyphs.into_iter().find(|g| g.byte == start).map(|g| (g.font_id, g.gid)) + }; + let state = |s: &Session| (s.doc().unwrap().history.undo.len(), s.doc().unwrap().revision); + + for overflow in [false, true] { + let mut s = session(); + let mut spec = json!({"x": 0, "y": 0, "text": text, "size": 20, "font": alternates_family(true)}); + if overflow { + spec["area"] = json!({"width": 200, "height": 2}); + } + let id = NodeId(s.execute("text.create", &spec).unwrap()["id"].as_u64().unwrap()); + let before = obj(&s, id); + assert_eq!(drawn(&before), Some((fallback.id(), default))); + if overflow { + assert!(vectorcraft_text::layout(db, &before).glyphs.is_empty(), "the original frame overflows"); + } + let contextual = vectorcraft_text::alternates_in_context(db, &before, start).expect("the panel's contextual alternates"); + assert_eq!((contextual.font_id, contextual.default), (fallback.id(), default)); + assert!(contextual.alternates.iter().any(|a| a.gid == alternate && a.apply.is_none())); + + let unchanged = state(&s); + for gid in [primary.glyph_for('a'), fallback.glyph_count()] { + let err = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": gid})).unwrap_err(); + assert!(err.to_string().contains("is not an alternate"), "{err}"); + assert_eq!(state(&s), unchanged, "a refused choice makes no edit"); + assert_eq!(obj(&s, id).runs, before.runs); + } + let result = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": alternate})).unwrap(); + assert_eq!(result, json!({"id": id.0, "start": start, "end": end, "gid": alternate})); + assert_eq!(state(&s).0, unchanged.0 + 1, "one undo step"); + let chosen = obj(&s, id); + assert_eq!(chosen.plain_text(), text); + assert_eq!(drawn(&chosen), Some((fallback.id(), alternate))); + let choice = chosen.runs.iter().find_map(|r| r.style.glyph_alternate.as_ref()).expect("the stored choice"); + assert_eq!(choice, &fallback.choice(CH, alternate), "the choice keeps the supplying face's identity"); + + let mut wrong_face = chosen.clone(); + wrong_face.runs.iter_mut().find(|r| r.style.glyph_alternate.is_some()).unwrap().style.glyph_alternate = Some(primary.choice(CH, alternate)); + assert_eq!(drawn(&wrong_face), Some((fallback.id(), default)), "another face's choice is not drawn"); + + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + let reopened = vectorcraft_format::load(&bytes).unwrap(); + let NodeKind::Text(reopened) = &reopened.node(id).unwrap().kind else { panic!("text missing") }; + assert_eq!(drawn(reopened), Some((fallback.id(), alternate)), "native restore retains the face and glyph"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(obj(&s, id).runs, before.runs, "Undo restores the original runs"); + assert_eq!(drawn(&obj(&s, id)), Some((fallback.id(), default))); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(drawn(&obj(&s, id)), Some((fallback.id(), alternate)), "Redo restores the choice"); + + let result = s.execute("text.setGlyphAlternate", &json!({"id": id.0, "start": start, "gid": null})).unwrap(); + assert_eq!(result["gid"], json!(default), "clearing reports the fallback's shaped glyph"); + assert_eq!(drawn(&obj(&s, id)), Some((fallback.id(), default))); + assert!(obj(&s, id).runs.iter().all(|r| r.style.glyph_alternate.is_none())); + } +} + #[test] fn glyph_alternates_query_reports_the_fallback_glyph_that_is_drawn() { use vectorcraft_text::test_fonts::{alternates_family, alternates_font}; diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs index 435bbc4ed7..b181672569 100644 --- a/crates/text/src/harakat.rs +++ b/crates/text/src/harakat.rs @@ -37,10 +37,11 @@ pub(crate) fn is_harakat(c: char) -> bool { } /// Do character styles `a` and `b` differ, if at all, only in paint: fill, stroke and how the -/// stroke is drawn, overprint, and the character style name? Those are not read by the shaper -/// and change no glyph, advance or position. Nor is the space before a line's first character -/// inside a cluster, which takes it from its first character (#5111), so it is ignored too. Stricter than [`crate::shape`]'s `shapes_alike` -/// (which also ignores baseline shift, tracking, underline, …). +/// stroke is drawn, overprint, and the character style name? OpenType feature differences are +/// ignored here: they already apply to their own character ranges during shaping. Nor is the +/// space before a line's first character read inside a cluster, which takes it from its first +/// character (#5111). Stricter than [`crate::shape`]'s `shapes_alike` (which also ignores baseline +/// shift, tracking, underline, …). pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { // Exhaustive: a new attribute must explicitly keep or relax this boundary rule. let CharStyle { @@ -61,7 +62,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { underline, strikethrough, all_caps, - features, + features: _, position, small_caps, char_align, @@ -110,7 +111,6 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { && underline == &b.underline && strikethrough == &b.strikethrough && all_caps == &b.all_caps - && features == &b.features && position == &b.position && small_caps == &b.small_caps && char_align == &b.char_align @@ -129,7 +129,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { } /// Is the run boundary at byte `at` of `text`, between styles `before` and `after`, one that -/// stays inside a shaping cluster: they differ only in paint and a harakat is on either side? +/// stays inside a shaping cluster: only paint or features differ and a harakat is on either side? pub(crate) fn paint_only_mark_boundary(text: &str, at: usize, before: &CharStyle, after: &CharStyle) -> bool { let next = text.get(at..).and_then(|s| s.chars().next()); let previous = text.get(..at).and_then(|s| s.chars().next_back()); diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 9cb40448d8..c1e2b9e0f5 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -875,24 +875,103 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: source.get(i).filter(|(range, _)| range.contains(&byte)).map(|_| span.start + i) } -/// Can one glyph draw characters in styles `a` and `b`? Only when the styles are equal, except that -/// horizontal type (`vertical` false) does not read Standard Vertical Roman Alignment or the -/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. The space -/// before a line's first character ([`space_before`]) is never read inside a cluster, which takes it -/// from its first character, so it doesn't split a ligature either. +/// Can one glyph draw characters in styles `a` and `b`? OpenType features already apply to their +/// own character ranges during shaping, so feature-only differences do not split a cluster. +/// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets. +/// The space before a line's first character ([`space_before`]) comes from the cluster's first +/// character, so it does not split a ligature either. fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { - let b = &CharStyle { kerning_before: a.kerning_before, ..b.clone() }; - if a == b { - return true; - } - !vertical - && *a - == CharStyle { - standard_vertical_roman: a.standard_vertical_roman, - tcy_up_down: a.tcy_up_down, - tcy_left_right: a.tcy_left_right, - ..b.clone() - } + // Exhaustive, like `shapes_alike`: compare borrowed fields without copying feature lists. + let CharStyle { + font_family, + font_style, + font_version, + font_axes, + size, + leading, + tracking, + kerning, + kerning_before, + optical_kerning, + baseline_shift, + h_scale, + v_scale, + rotation, + fill, + stroke, + stroke_width, + underline, + strikethrough, + all_caps, + features: _, + style_name, + overprint_fill, + overprint_stroke, + stroke_cap, + stroke_join, + stroke_miter_limit, + stroke_dash, + stroke_align, + position, + small_caps, + char_align, + char_direction, + kashidas, + proportional_metrics, + tate_chu_yoko, + tcy_up_down, + tcy_left_right, + standard_vertical_roman, + digits, + font_height_reference, + no_break, + language, + glyph_alternate, + } = a; + // Keep PartialEq's non-reflexive behavior for ignored numeric fields, without cloning styles. + let offsets = (standard_vertical_roman, tcy_up_down, tcy_left_right); + let other_offsets = if vertical { (&b.standard_vertical_roman, &b.tcy_up_down, &b.tcy_left_right) } else { offsets }; + kerning_before == &a.kerning_before + && offsets == other_offsets + && font_family == &b.font_family + && font_style == &b.font_style + && font_version == &b.font_version + && font_axes == &b.font_axes + && size == &b.size + && leading == &b.leading + && tracking == &b.tracking + && kerning == &b.kerning + && optical_kerning == &b.optical_kerning + && baseline_shift == &b.baseline_shift + && h_scale == &b.h_scale + && v_scale == &b.v_scale + && rotation == &b.rotation + && fill == &b.fill + && stroke == &b.stroke + && stroke_width == &b.stroke_width + && underline == &b.underline + && strikethrough == &b.strikethrough + && all_caps == &b.all_caps + && style_name == &b.style_name + && overprint_fill == &b.overprint_fill + && overprint_stroke == &b.overprint_stroke + && stroke_cap == &b.stroke_cap + && stroke_join == &b.stroke_join + && stroke_miter_limit == &b.stroke_miter_limit + && stroke_dash == &b.stroke_dash + && stroke_align == &b.stroke_align + && position == &b.position + && small_caps == &b.small_caps + && char_align == &b.char_align + && char_direction == &b.char_direction + && kashidas == &b.kashidas + && proportional_metrics == &b.proportional_metrics + && tate_chu_yoko == &b.tate_chu_yoko + && digits == &b.digits + && font_height_reference == &b.font_height_reference + && no_break == &b.no_break + && language == &b.language + && glyph_alternate == &b.glyph_alternate } /// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text? @@ -1151,8 +1230,8 @@ fn is_mark(c: char) -> bool { /// characters with automatic kerning: the manual value replaces the font's pair after its /// character, and a character before it with automatic kerning keeps the font's pair with it /// (except before a character that All Caps or Small Caps sets as several letters, such as ß as -/// SS). A cluster that would take in characters of runs with different styles (a ligature, a -/// base with its marks) is not formed: the segment is shaped in pieces at those run boundaries. +/// SS). A cluster that would take in characters of runs with different drawing attributes (a +/// ligature, a base with its marks) is not formed, except for paint-only harakat differences. fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFeatures, out: &mut Vec) { let Segment { range, runs, source_runs, st, face, small, level } = seg; let (range, small) = (range.clone(), *small); @@ -1426,8 +1505,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe cluster_starts.dedup(); // A glyph is drawn in one style: where a cluster would take in characters of runs in different // styles, the runs are shaped apart, as runs that do not shape alike are. - // Except at harakat styled apart only in paint ([`crate::harakat`]), and, in horizontal type, where - // the runs differ only in settings that vertical type alone reads. + // Except at harakat styled apart only in paint ([`crate::harakat`]), feature-only boundaries, + // and, in horizontal type, settings that vertical type alone reads. let inside_clusters: Vec = boundaries() .filter(|&b| cluster_starts.binary_search(&b).is_err() && !drawn_alike(run_at(b - 1).1, run_at(b).1, feats.vertical)) .filter(|b| mark_boundaries.binary_search(b).is_err()) diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index e9bb2917ab..2d766ae571 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -990,6 +990,47 @@ pub fn aliased_joining_font() -> Option> { Some(builder.build()) } +/// The family and private-use character of [`contextual_fallback_font`]. +pub const CONTEXTUAL_FALLBACK_FAMILY: &str = "Contextual Fallbacktest Sans"; +pub const CONTEXTUAL_FALLBACK_CHAR: char = '\u{F6084}'; + +/// A fallback fixture with coverage no bundled face shares: `ccmp` draws its private-use +/// character (n) as m, whose `cv01` alternate d has no applicable character-style feature. +pub fn contextual_fallback_font() -> Option> { + use write_fonts::tables::gsub::{Gsub, SingleSubst, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList}; + use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord}; + use write_fonts::types::GlyphId16; + + let base = remapped_source_sans( + CONTEXTUAL_FALLBACK_FAMILY, + "ContextualFallbacktestSans-Regular", + &[(CONTEXTUAL_FALLBACK_CHAR, 'n'), ('a', 'a'), (' ', ' ')], + )?; + let font = skrifa::FontRef::new(&base).ok()?; + let [n, m, d] = source_sans_gids(['n', 'm', 'd'])?; + let single = |from, to| { + SubstitutionLookup::Single(Lookup::new( + Default::default(), + vec![SingleSubst::from(SingleSubstFormat2::new(CoverageFormat1::new(vec![GlyphId16::new(from)]).into(), vec![GlyphId16::new(to)]))], + )) + }; + let lookups = SubstitutionLookupList::new(vec![single(n, m), single(m, d)]); + let features = FeatureList::new(vec![ + FeatureRecord::new(Tag::new(b"ccmp"), Feature::new(None, vec![0])), + FeatureRecord::new(Tag::new(b"cv01"), Feature::new(None, vec![1])), + ]); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0, 1])), vec![]))]); + let mut builder = FontBuilder::new(); + builder.add_table(&Gsub::new(scripts, features, lookups)).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`ligature_caret_font`]. pub const LIGATURE_CARET_FAMILY: &str = "Carettest Sans"; diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index 9bb7700d66..b50b39da83 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -152,13 +152,13 @@ fn paint_only_differences_are_told_apart() { assert_eq!(harakat_ranges("اللَّهِ", 0.."اللَّهِ".len()), [6..10, 12..14], "shadda and fatha together"); } -/// The allocation-free comparison must keep the previous comparison's complete contract, -/// including attributes that affect layout rather than shaping and non-reflexive file values. +/// Compare every drawing attribute without allocations, retaining PartialEq for non-reflexive +/// values while allowing features already resolved by shaping. #[test] -fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { +fn paint_only_keeps_every_drawing_attribute_and_partial_equality() { use vectorcraft_doc::{CharAlign, CharPosition, Dash, Digits, FontHeightReference, GlyphChoice, LineCap, LineJoin, ParaDirection, StrokeAlign}; - fn previous(a: &CharStyle, b: &CharStyle) -> bool { + fn normalized(a: &CharStyle, b: &CharStyle) -> bool { let mut b = b.clone(); b.fill = a.fill.clone(); b.stroke = a.stroke.clone(); @@ -172,12 +172,13 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { b.stroke_align = a.stroke_align; b.style_name = a.style_name.clone(); b.kerning_before = a.kerning_before; + b.features = a.features.clone(); *a == b } fn check(a: &CharStyle, b: &CharStyle, want: bool, name: &str) { for (left, right) in [(a, b), (b, a)] { - assert_eq!(paint_only(left, right), previous(left, right), "{name}: old contract"); + assert_eq!(paint_only(left, right), normalized(left, right), "{name}: normalized contract"); } assert_eq!(paint_only(a, b), want, "{name}"); } @@ -213,7 +214,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { field!(underline, true, false); field!(strikethrough, true, false); field!(all_caps, true, false); - field!(features, vec!["-liga".into()], false); + field!(features, vec!["-liga".into()], true); field!(position, CharPosition::Superscript(Default::default()), false); field!(small_caps, Some(80.0), false); field!(char_align, CharAlign::EmBoxTop, false); @@ -294,6 +295,42 @@ fn synthetic_harakat_keep_geometry_with_and_without_gdef_marks() { } } +#[test] +fn feature_only_harakat_boundaries_keep_attachment_and_ownership() { + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(crate::test_fonts::lam_alef_font(b"rlig").expect("the test font builds")), 1); + let family = crate::test_fonts::lam_alef_family(b"rlig"); + let face = db.face(family, "Regular").expect("the test face"); + const WORD: &str = "بَبَبَ"; + let plain = TextObject::point(Point::ZERO, WORD, style(family)); + let before = layout(&db, &plain); + assert_eq!(before.glyphs.len(), 6, "three bases with their attached marks"); + assert!(before.glyphs.iter().any(|g| is_gdef_mark(&face, g.gid))); + for feature_marks in [false, true] { + for coloured in [false, true] { + let mut text = plain.clone(); + for (byte, ch) in WORD.char_indices() { + crate::edit::style_range(&mut text.runs, byte, byte + ch.len_utf8(), |st| { + if is_harakat(ch) == feature_marks { + st.features = vec!["salt=2".into()]; + } + if coloured && is_harakat(ch) { + st.fill = red(); + } + }); + } + let after = layout(&db, &text); + assert_eq!(geometry(&after), geometry(&before), "features on marks {feature_marks}, coloured {coloured}"); + for glyph in &after.glyphs { + let mark = is_gdef_mark(&face, glyph.gid); + let owner = &run_of(&text, glyph).style; + assert_eq!(!owner.features.is_empty(), mark == feature_marks, "each glyph keeps its own character's features"); + assert_eq!(owner.fill == red(), coloured && mark, "only marks take the mark paint"); + } + } + } +} + /// A range is normalised as `edit::style_range` takes one: clamped, each end moved back to the /// start of its character, put in order. Never a silent empty result for a usable range. #[test] diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index a1e06887a7..ce0abc40c1 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -629,6 +629,65 @@ fn an_upright_ligature_takes_its_caret_down_its_cell() { assert!((at(900) - 0.5).abs() < 1e-9, "{}", at(900)); } +#[test] +fn feature_only_settings_keep_a_ligature_whole() { + let db = db_with(vertical_ligature_caret_font(280)); + for vertical in [false, true] { + let mut text = point("fi", VERTICAL_LIGATURE_CARET_FAMILY); + text.vertical = vertical; + let before = layout(&db, &text); + assert_eq!(before.glyphs.len(), 1, "the fixture ligates fi"); + let plain = &before.glyphs[0]; + for byte in [0, 1] { + for feature in ["salt=2", "ss03"] { + let mut featured = text.clone(); + crate::edit::style_range(&mut featured.runs, byte, byte + 1, |st| st.features = vec![feature.into()]); + let after = layout(&db, &featured); + assert_eq!(after.glyphs.len(), 1, "{feature} at {byte}, vertical {vertical}: an unchanged fi stays ligated"); + let glyph = &after.glyphs[0]; + assert_eq!((glyph.gid, glyph.byte, glyph.len), (plain.gid, 0, 2)); + assert_eq!(glyph.origin, plain.origin); + assert_eq!(glyph.xf, plain.xf); + assert_eq!(glyph.advance, plain.advance); + assert_eq!(glyph.carets, plain.carets); + } + } + } +} + +#[test] +fn drawing_differences_still_split_a_feature_styled_ligature() { + let db = db_with(ligature_caret_font(None)); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test face"); + let changes: [fn(&mut CharStyle); 3] = + [|st| st.fill = vectorcraft_doc::color::Paint::None, |st| st.baseline_shift = 10.0, |st| st.underline = true]; + for (i, change) in changes.into_iter().enumerate() { + for byte in [0, 1] { + let mut text = point("fi", LIGATURE_CARET_FAMILY); + crate::edit::style_range(&mut text.runs, byte, byte + 1, |st| { + st.features = vec!["salt=2".into()]; + change(st); + }); + let after = layout(&db, &text); + assert_eq!(after.glyphs.iter().map(|g| g.gid).collect::>(), [face.glyph_for('f'), face.glyph_for('i')], "attribute {i} at {byte}"); + for (byte, glyph) in after.glyphs.iter().enumerate() { + assert_eq!((glyph.byte, glyph.len, glyph.run), (byte, 1, byte), "split glyphs keep their own drawing style"); + } + } + } +} + +#[test] +fn a_ligature_feature_still_applies_only_to_its_character_range() { + let db = db_with(ligature_caret_font(None)); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").expect("the test face"); + let mut text = point("fifi", LIGATURE_CARET_FAMILY); + crate::edit::style_range(&mut text.runs, 0, 1, |st| st.features = vec!["-liga".into()]); + let after = layout(&db, &text); + let glyphs = after.glyphs.iter().map(|g| (g.gid, g.byte, g.len)).collect::>(); + assert_eq!(glyphs, [(face.glyph_for('f'), 0, 1), (face.glyph_for('i'), 1, 1), (face.glyph_for(LIGATURE_CARET_GLYPH), 2, 2)]); +} + /// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets, so /// setting one of them on the f of "fi" keeps the ligature. Vertical type reads them per glyph, so /// there the f is shaped apart from the i. diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 7126c1b506..7c0a18d14f 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -198,23 +198,26 @@ fn selected_target(app: &VectorcraftApp) -> Option> { None } -/// Insert `ch` at the Type tool's caret, else append it to the selected text. -fn insert(app: &mut VectorcraftApp, ctx: &egui::Context, ch: char) { +/// Insert `ch` at the Type tool's caret, else append it to the selected text; whether it succeeded. +fn insert(app: &mut VectorcraftApp, ctx: &egui::Context, ch: char) -> bool { let s = ch.to_string(); if app.session.tool_wants_text() { let v = app.view_info(); let target_before = crate::ime::target(&mut app.session, v); let before = crate::ime::stamp(&mut app.session, v); let r = app.session.tool_text(&s, v); + let inserted = r.is_ok(); match &r { Err(e) => app.status(e.to_string()), Ok(_) => crate::ime::note_application_edit(app, ctx, v, target_before, before), } app.sync_views(); + inserted } else if super::character::text_style(app).is_some() { - app.run("type.insert", json!({"text": s})).ok(); + app.run("type.insert", json!({"text": s})).is_ok() } else { app.ui.status = "Select text or click into text with the Type tool to insert glyphs".into(); + false } } @@ -714,13 +717,16 @@ fn encoded_tip(face: &vectorcraft_text::FontFace, c: char, gid: u32) -> String { fn insert_character(app: &mut VectorcraftApp, ctx: &egui::Context, face: &vectorcraft_text::FontFace, c: char, gid: u32) { match base_letter(face, c, gid) { Some(base) => { - insert(app, ctx, base); - app.status(crate::i18n::fmt( - tl!("Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position."), - &[("base", &base.to_string()), ("code", &format!("{:04X}", base as u32))], - )); + if insert(app, ctx, base) { + app.status(crate::i18n::fmt( + tl!("Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position."), + &[("base", &base.to_string()), ("code", &format!("{:04X}", base as u32))], + )); + } + } + None => { + insert(app, ctx, c); } - None => insert(app, ctx, c), } } @@ -1655,4 +1661,30 @@ mod tests { assert_eq!(text.plain_text(), "a"); } } + + #[test] + fn a_normalized_glyph_without_a_text_target_preserves_the_insertion_error() { + use vectorcraft_text::test_fonts::{ALIASED_JOINING_FAMILY, aliased_joining_font}; + let db = FontDb::global(); + db.add_font(aliased_joining_font().expect("the test font builds")); + let face = db.face(ALIASED_JOINING_FAMILY, "Regular").expect("the test face"); + let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).collect::>(); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let undo = app.session.active().unwrap().history.undo.len(); + let ctx = egui::Context::default(); + set_pstate(&ctx, "gl-font", Some((ALIASED_JOINING_FAMILY.to_string(), "Regular".to_string()))); + let cells = inks(&tall_shapes(&ctx, &mut app)); + let at = |c| chars.iter().position(|(ch, _)| *ch == c).expect("the encoded glyph"); + double_click_in(&ctx, &mut app, cells[at('B')].0.center(), TALL); + let failed = app.ui.status.clone(); + assert!(!failed.is_empty(), "the ordinary character reports the missing text target"); + double_click_in(&ctx, &mut app, cells[at('\u{FE91}')].0.center(), TALL); + assert_eq!(app.ui.status, failed, "normalization must not overwrite the insertion failure with a success"); + let state = app.session.active().unwrap(); + assert_eq!(state.history.undo.len(), undo); + let mut texts = 0; + state.doc.walk(|n| texts += usize::from(matches!(n.kind, NodeKind::Text(_)))); + assert_eq!(texts, 0, "neither glyph created or changed text"); + } } diff --git a/docs/type-parity.md b/docs/type-parity.md index 7917c0cf3d..058ec947fa 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -2,7 +2,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (kerning at the Type tool's caret at the start of a line sets the space before the character after it, #5111; previously minor: type's strokes in an envelope paint along its bent glyph outlines at their own weight, Distort Appearance on or off, as users' `.ai` files draw them, #6644; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; 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: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) -Glyphs review (#6084): a positional variant no supported feature draws is accepted as a glyph choice. Entire Font respects the selection's variable axes and font version; the default cell can reset an unlisted choice without GSUB alternates. Encoded characters insert unchanged except Arabic presentation forms with a joining base letter. Scores and estimates unchanged. +Glyphs review (#6084): a positional variant no supported feature draws is accepted as a glyph choice. Entire Font respects the selection's variable axes and font version; the default cell can reset an unlisted choice without GSUB alternates. Contextual choices use the supplying face, including fallback. Feature-only boundaries preserve ligature clusters and Arabic mark attachment. Encoded characters insert unchanged except Arabic presentation forms with a joining base letter; normalization reports success only after insertion succeeds. Latest follow-up regressions await runtime verification. Scores and estimates unchanged. Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -84,6 +84,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Glyphs review (#6084): validate contextual glyph choices against their supplying face, including fallback and overflow; keep feature-only boundaries inside ligatures and Arabic mark clusters while retaining drawing-attribute splits; preserve insertion failures instead of reporting normalization success. Deterministic engine, shaping and panel regression sources added; runtime proof pending. Scores and estimates unchanged. | | 2026-11-12 | minor | Glyphs review (#6084): Entire Font resolves the selected font's version and variable axes; the default cell remains available to reset an unlisted choice with no listed alternates. Character insertion normalizes only Arabic presentation forms, preserving ordinary characters whose glyphs share Arabic substitution provenance. Add deterministic panel apply/reset/Undo regressions without OS-font dependencies. Scores and estimates unchanged. | | 2026-11-09 | minor | Glyphs review (#6084): the engine accepts a positional alternate as a glyph choice when no supported feature draws it, not only an isolated character's alternates; both panel views exercise apply and Undo. OpenType style options preserve only a valid last on-value when enabling a stylistic set. Scores and estimates unchanged. | | 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, applies the ones that are the selected letter's alternates, or that no feature reaches and the font's names do not give to another letter, and dims, with the reason, those that are not. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. | From 28dfd66b65b30240b14e8f6257c3094d9d9a6eda Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:51:42 -0800 Subject: [PATCH 34/79] Images: a quarter turn by the orientation swaps the resolution, and every TIFF follows the tag Opening or placing an image whose orientation turns it a quarter (EXIF or TIFF Orientation 5 to 8) stored the pixels turned but kept the resolution as stored, so a photo at 300 x 150 ppi came in at the wrong width and height. XResolution counts pixels in the ImageWidth direction of the stored image (TIFF 6.0, https://www.itu.int/itudoc/itu-t/com16/tiff-fx/docs/tiff6.pdf; EXIF uses the same tag, CIPA DC-008-2012, https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf), so a quarter turn now swaps the axes, in the size Open and Place use and in the stored image, with jxlread::oriented_resolution as JPEG XL does. Three kinds of TIFF did not follow their Orientation tag: - grayscale TIFFs with extra samples and 1-bit CCITT Group 3 TIFFs were not turned; - a CMYK TIFF with its inks together was turned but stored as RGB, losing its inks, and one with a plane per ink did not open ("Planar TIFF with CMYK color type is not supported"). They now turn as the other images do, and a CMYK TIFF keeps its inks, 8 bits each, written again with the turned resolution. --- crates/doc/src/cmyk.rs | 9 +- crates/engine/src/cmd/fileio/load.rs | 52 +++++-- crates/engine/src/cmd/fileio/tests.rs | 207 ++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index e487226d4f..a3a6f7b016 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -133,6 +133,13 @@ pub(crate) fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Deco Some((d.colortype().ok()?, d)) } +/// The Orientation tag of TIFF `bytes`' first image, of any unsigned type; `None` for other images +/// and TIFFs without one. Reads only its header. +pub fn tiff_orientation(bytes: &[u8]) -> Option { + let (_, mut d) = tiff(bytes)?; + d.find_tag_unsigned::(tiff::tags::Tag::Orientation).ok().flatten() +} + /// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)? fn planar(d: &mut tiff::decoder::Decoder>) -> bool { d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1) @@ -299,7 +306,7 @@ fn jpeg_inks(bytes: &[u8]) -> Option { /// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane /// per ink. -fn tiff_inks(bytes: &[u8]) -> Option { +pub fn tiff_inks(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index fde1ed273f..7df402724a 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -814,6 +814,9 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// A TIFF with more strips or tiles than the decoder can count /// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are /// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in. +/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is +/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK TIFF +/// keeps its inks, 8 bits each ([`turned_cmyk_tiff`]). pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -852,7 +855,15 @@ pub fn raster_image(bytes: &[u8]) -> Result { let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?; // A photo whose EXIF orientation turns or flips it (a camera held upright) is stored as it is // seen, so it places the way it was taken (#4284); the others keep their own bytes. - let turned = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff") && exif_orientation(bytes).is_some(); + let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten(); + let turned = orientation.is_some(); + // The resolution describes the stored pixels; a quarter turn swaps its axes. + let shown_ppi = jxlread::oriented_resolution(ppi, orientation.map_or(1, |o| u32::from(o.to_exif()))); + if let Some(orientation) = orientation.filter(|_| f.id == "tiff") + && let Some(stored) = turned_cmyk_tiff(bytes, orientation, shown_ppi) + { + return stored; + } let kept = match f.id { _ if turned => None, "png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()), @@ -868,36 +879,53 @@ pub fn raster_image(bytes: &[u8]) -> Result { vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) }; let mut img = match (f.id == "tiff").then(extra_samples).flatten() { - Some(img) => img, + Some(img) => image::DynamicImage::ImageRgba8(img), None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() { - Some(read) => read.map_err(err)?, + Some(read) => image::DynamicImage::ImageRgba8(read.map_err(err)?), None => { use image::ImageDecoder as _; - let mut decoder = reader.into_decoder().map_err(err)?; + let decoder = reader.into_decoder().map_err(err)?; // The allowance counts the decoded samples and the RGBA pixels they become (#1717). let (w, h) = decoder.dimensions(); if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC { return Err(err(format!("an image of {w} × {h} pixels is too large to draw"))); } - let orientation = decoder.orientation().unwrap_or(image::metadata::Orientation::NoTransforms); - let mut img = image::DynamicImage::from_decoder(decoder).map_err(err)?; - img.apply_orientation(orientation); - img.to_rgba8() + image::DynamicImage::from_decoder(decoder).map_err(err)? } }, }; + img.apply_orientation(orientation.unwrap_or(image::metadata::Orientation::NoTransforms)); + let mut img = img.into_rgba8(); if f.id == "tga" { super::tgaread::fix_alpha(bytes, &mut img); } - as_png(img, None, ppi) + as_png(img, None, shown_ppi) } /// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when -/// it leaves them as stored, or the image has none. +/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with +/// extra samples) has its Orientation tag read from its first image +/// ([`vectorcraft_doc::cmyk::tiff_orientation`]). pub(crate) fn exif_orientation(bytes: &[u8]) -> Option { use image::ImageDecoder as _; - let mut decoder = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?; - decoder.orientation().ok().filter(|o| *o != image::metadata::Orientation::NoTransforms) + use image::metadata::Orientation; + let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok(); + let tagged = || vectorcraft_doc::cmyk::tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?)); + decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms) +} + +/// CMYK TIFF `bytes` turned or flipped by their `orientation`, as [`raster_image`] stores them: +/// their ink amounts ([`vectorcraft_doc::cmyk::tiff_inks`], 8 bits each) turned and written again +/// by [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi` of the turned pixels. `None` for other +/// images and for CMYK TIFFs whose inks it doesn't read. +fn turned_cmyk_tiff(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option> { + let inks = vectorcraft_doc::cmyk::tiff_inks(bytes)?; + // Four 8-bit inks to a pixel, turned as four 8-bit channels are. + let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?); + px.apply_orientation(orientation); + let inks = vectorcraft_doc::cmyk::Inks::new(px.width(), px.height(), px.into_bytes())?; + let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; + Some(stored(std::sync::Arc::unwrap_or_clone(tiff.bytes), "image/tiff", (inks.width, inks.height), ppi)) } /// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index c90e077eb4..5f508bd6ec 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -907,6 +907,213 @@ fn a_photo_places_the_way_its_exif_orientation_says() { assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "the left half is on top"); } +/// Where the first stored pixel shows once EXIF (or TIFF) `orientation` turns the image, as +/// (right, bottom): orientations 1 and 5 keep it at the top left, 2 and 6 put it at the top right, +/// 3 and 7 at the bottom right, 4 and 8 at the bottom left. Values outside 1 to 8 leave it. +fn first_pixel_at(orientation: u16) -> (bool, bool) { + match orientation { + 2 | 6 => (true, false), + 3 | 7 => (true, true), + 4 | 8 => (false, true), + _ => (false, false), + } +} + +/// A little-endian EXIF block (a TIFF header and directory, as a PNG `eXIf` chunk or a WebP +/// `EXIF` chunk holds it) with an Orientation of `orientation` and an XResolution and YResolution +/// of `x` and `y` pixels per inch. +fn exif_block(orientation: u16, (x, y): (u32, u32)) -> Vec { + let entry = |tag: u16, kind: u16, value: u32| [&tag.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &value.to_le_bytes()].concat(); + // Orientation, XResolution, YResolution, ResolutionUnit inches; the two rationals follow. + [ + b"II".as_slice(), + &[0x2a, 0, 8, 0, 0, 0], + &4u16.to_le_bytes(), + &entry(0x0112, 3, u32::from(orientation)), + &entry(0x011a, 5, 62), + &entry(0x011b, 5, 70), + &entry(0x0128, 3, 2), + &[0, 0, 0, 0], + &[x.to_le_bytes(), 1u32.to_le_bytes(), y.to_le_bytes(), 1u32.to_le_bytes()].concat(), + ] + .concat() +} + +/// A photo turned a quarter by its EXIF orientation keeps its physical size: the resolution's +/// axes swap with the pixels', in what Place reports and in the stored image. Every orientation +/// puts the first stored pixel in its corner once, and the stored image, read again, is not turned +/// a second time. Values outside 1 to 8 leave the photo as stored. +#[test] +fn a_quarter_turn_swaps_the_resolution_axes() { + // 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) red, the rest white. + let rgb = image::RgbImage::from_fn(48, 32, |x, y| if x < 16 && y < 16 { image::Rgb([220, 0, 0]) } else { image::Rgb([255, 255, 255]) }); + let mut jpg = Vec::new(); + image::DynamicImage::ImageRgb8(rgb).write_to(&mut Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap(); + for orientation in [0u16, 1, 2, 3, 4, 5, 6, 7, 8, 9] { + let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat(); + // Without a JFIF density the resolution comes from the EXIF block. + let mut turned = jpg[..2].to_vec(); + turned.extend([0xff, 0xe1]); + turned.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + turned.extend(&exif); + turned.extend(&jpg[2..]); + let what = format!("orientation {orientation}"); + assert_eq!(super::ppi::resolution(&turned), Some((300.0, 150.0)), "{what}: as stored"); + let quarter = (5..=8).contains(&orientation); + let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) }; + let img = raster_image(&turned).unwrap(); + assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_rgb8(); + let red = |x: u32, y: u32| px.get_pixel(x, y)[1] < 80; + let (w, h) = size; + let corners = [(false, false), (true, false), (true, true), (false, true)]; + let reds: Vec<_> = + corners.into_iter().filter(|&(right, bottom)| red(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 })).collect(); + assert_eq!(reds, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner"); + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.ppi), (size, Some(ppi)), "{what}: read again, it isn't turned again"); + } +} + +/// `tiff` (little-endian) with an Orientation tag (274) of `value`, a SHORT or, when `long`, a +/// LONG, and, when `y_ppi` is given, a YResolution (283, which `tiff` must have) of that many +/// pixels per inch: its first directory copied to the end of the file with the tag added in order, +/// and the header pointing there, so that every other offset in the file still holds. +fn tiff_oriented(tiff: &[u8], value: u16, long: bool, y_ppi: Option) -> Vec { + let at = usize::try_from(u32::from_le_bytes(tiff[4..8].try_into().unwrap())).unwrap(); + let n = usize::from(u16::from_le_bytes([tiff[at], tiff[at + 1]])); + let mut entries: Vec> = tiff[at + 2..at + 2 + 12 * n].chunks(12).map(<[u8]>::to_vec).collect(); + let i = entries.iter().position(|e| u16::from_le_bytes([e[0], e[1]]) > 274).unwrap_or(n); + let (kind, v) = if long { (4u16, u32::from(value).to_le_bytes()) } else { (3, [value.to_le_bytes(), [0, 0]].concat().try_into().unwrap()) }; + entries.insert(i, [&274u16.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &v].concat()); + let mut out = tiff.to_vec(); + if out.len() % 2 == 1 { + out.push(0); + } + if let Some(y) = y_ppi { + let rational = u32::try_from(out.len()).unwrap(); + out.extend([y.to_le_bytes(), 1u32.to_le_bytes()].concat()); + let e = entries.iter_mut().find(|e| u16::from_le_bytes([e[0], e[1]]) == 283).unwrap(); + e[8..12].copy_from_slice(&rational.to_le_bytes()); + } + let ifd = u32::try_from(out.len()).unwrap(); + out[4..8].copy_from_slice(&ifd.to_le_bytes()); + out.extend(u16::try_from(n + 1).unwrap().to_le_bytes()); + out.extend(entries.concat()); + out.extend([0; 4]); + out +} + +/// TIFFs the image decoder can't read, grayscale with an alpha channel and 1-bit ones compressed +/// with CCITT Group 3, turn and flip as their Orientation tag says, as the others do (#4284), +/// whether the tag is a SHORT or a LONG. A quarter turn swaps the resolution's axes. +#[test] +fn tiffs_read_without_the_image_decoder_follow_their_orientation() { + // 3 × 2 at 150 × 300 ppi, the first pixel black and the rest white, opaque. + let gray = hand_tiff(1, 8, &[2], false, &[vec![0, 255, 255, 255, 255, 255], vec![255; 6]]); + let rows: Vec> = (0..2).map(|y| (0..3).map(|x| x == 0 && y == 0).collect()).collect(); + let g = vectorcraft_testkit::tiff::Group3 { + rows: &rows, + compression: 2, + t4_options: 0, + photometric: 0, + fill_order: 1, + rows_per_strip: 2, + tile: None, + rtc: false, + }; + let fax = vectorcraft_testkit::tiff::group3(&g); + // The Group 3 file declares no resolution. + for (kind, file, y_ppi) in [("Group 3", fax, None), ("gray with alpha", gray, Some(300))] { + assert!(image::load_from_memory(&file).is_err(), "{kind}: the image decoder reads it now"); + for (orientation, long) in (1..=8).flat_map(|o| [(o, false), (o, true)]) { + let what = format!("{kind}, orientation {orientation}, LONG {long}"); + let img = raster_image(&tiff_oriented(&file, orientation, long, y_ppi)).unwrap_or_else(|e| panic!("{what}: {e}")); + let quarter = orientation >= 5; + let size = if quarter { (2, 3) } else { (3, 2) }; + let ppi = y_ppi.map(|y| if quarter { (f64::from(y), 150.0) } else { (150.0, f64::from(y)) }); + assert_eq!(((img.width, img.height), img.ppi), (size, ppi), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), ppi, "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8(); + let (right, bottom) = first_pixel_at(orientation); + let (x, y) = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect(); + assert_eq!(black, [(x, y)], "{what}: the first stored pixel's corner"); + } + } +} + +/// A CMYK TIFF that its Orientation tag turns or flips keeps its inks, turned, whether it stores +/// them together or one plane per ink, at 8 or 16 bits; a quarter turn swaps the resolution's axes. +/// Read again, the stored image is not turned a second time. +#[test] +fn cmyk_tiffs_turned_by_their_orientation_keep_their_inks() { + // 3 × 2 at 150 × 300 ppi: the first pixel full cyan, the others 30% black. + let first: [u8; 4] = [255, 0, 0, 0]; + let other: [u8; 4] = [0, 0, 0, 77]; + let channel = |i: usize| -> Vec { (0..6).map(|p| u16::from(if p == 0 { first[i] } else { other[i] })).collect() }; + for (bits, planar) in [(8, false), (8, true), (16, false), (16, true)] { + let channels: Vec> = (0..4).map(|i| channel(i).iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect(); + let file = hand_tiff(5, bits, &[], planar, &channels); + for orientation in 1..=8 { + let what = format!("{bits} bits, planar {planar}, orientation {orientation}"); + let img = raster_image(&tiff_oriented(&file, orientation, false, Some(300))).unwrap_or_else(|e| panic!("{what}: {e}")); + let quarter = orientation >= 5; + let size = if quarter { (2, 3) } else { (3, 2) }; + let ppi = if quarter { (300.0, 150.0) } else { (150.0, 300.0) }; + assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), "image/tiff"), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost")); + assert_eq!((inks.width, inks.height), size, "{what}: the inks' size"); + let (right, bottom) = first_pixel_at(orientation); + let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + for (i, px) in inks.data.chunks(4).enumerate() { + let (x, y) = (u32::try_from(i).unwrap() % size.0, u32::try_from(i).unwrap() / size.0); + assert_eq!(px, if (x, y) == at { first } else { other }, "{what}: the inks of pixel ({x}, {y})"); + } + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again"); + } + } +} + +/// A PNG (its `eXIf` chunk and `pHYs` resolution), a WebP (its `EXIF` chunk) and an RGB TIFF read +/// by the image decoder, turned a quarter by their orientation, swap their resolution's axes; a +/// half turn keeps them. +#[test] +fn png_webp_and_rgb_tiff_turned_a_quarter_swap_their_resolution() { + use image::ImageEncoder as _; + let rgb = image::RgbImage::from_fn(3, 2, |x, y| image::Rgb(if x == 0 && y == 0 { [0, 0, 0] } else { [255, 255, 255] })); + for orientation in [3u16, 6, 8] { + let mut png = Vec::new(); + let mut enc = image::codecs::png::PngEncoder::new(&mut png); + enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap(); + enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap(); + let png = super::ppi::with_png_resolution(&png, (150.0, 300.0)); + let mut webp = Vec::new(); + let mut enc = image::codecs::webp::WebPEncoder::new_lossless(&mut webp); + enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap(); + enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap(); + let channel = |c: usize| -> Vec { rgb.pixels().map(|p| u16::from(p[c])).collect() }; + let tiff = tiff_oriented(&hand_tiff(2, 8, &[], false, &[channel(0), channel(1), channel(2)]), orientation, false, Some(300)); + for (kind, file) in [("PNG", png), ("WebP", webp), ("TIFF", tiff)] { + let what = format!("{kind}, orientation {orientation}"); + assert_eq!(super::ppi::resolution(&file), Some((150.0, 300.0)), "{what}: as stored"); + let quarter = orientation >= 5; + let (size, ppi) = if quarter { ((2, 3), (300.0, 150.0)) } else { ((3, 2), (150.0, 300.0)) }; + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8(); + let (right, bottom) = first_pixel_at(orientation); + let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect(); + assert_eq!(black, [at], "{what}: the first stored pixel's corner"); + } + } +} + /// With `importer` named, a file up to the importer's limit is read whole and a larger one is an /// error, whatever its content. #[test] From a5d0fe7963b726908daf72d9485cb510fac21cab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 04:58:20 -0800 Subject: [PATCH 35/79] Tests: font menu and menu bar tests allow for fonts and plug-ins other tests change In a full parallel `cargo test -p vectorcraft-ui-egui` run, other tests add and remove composite fonts in the process-wide font database and remove plug-ins from the plug-in registry. Two tests failed when that happened between their reads, and passed when run alone. - `font_menu::tests::the_scroll_bar_leaves_the_stars_clear` starred `FontDb::global().families()`, but the menu lists the composite fonts first, so the top rows had no star. The test now turns on the starred filter and counts the rows it listed. - `tests::nativemenu::the_menu_bar_is_inert_while_a_file_dialog_is_open` required every menu row from before the dialog to be back afterwards. A row may now be missing only when the composite font or plug-in it names is gone from the database or the registry. - Four font menu tests took the first families from `menu_family_list()`, which can be a composite font that is removed before the frames run. They take them from the families that stay listed. The variable-filter test checks that every family listed before the click is listed again, instead of comparing two frames. --- crates/ui-egui/src/font_menu/tests.rs | 26 +++++++++++++++++-------- crates/ui-egui/src/tests/nativemenu.rs | 27 ++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index a054794379..259408711c 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -21,12 +21,13 @@ fn the_thai_filter_lists_fonts_with_thai() { /// stay clear of it. #[test] fn the_scroll_bar_leaves_the_stars_clear() { - // Every family starred, so each row draws its star. + // Every family starred, so each row draws its star. Tests running alongside add fonts and + // composite fonts to the process-wide database, which the menu lists too: the ★ filter keeps + // the rows to the families starred here. let all = vectorcraft_text::FontDb::global().families(); - let families = all.len(); let ctx = egui::Context::default(); let area = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(300.0, 160.0)); - let mut st = super::MenuState::default(); + let mut st = super::MenuState { favorites_only: true, ..Default::default() }; let look = super::MenuLook { samples: false, favorites: &all, ..Default::default() }; let mut stars = vec![]; for _ in 0..3 { @@ -45,6 +46,7 @@ fn the_scroll_bar_leaves_the_stars_clear() { }) .collect(); } + let families = st.rows.len(); assert!(!stars.is_empty() || families == 0, "{families} families, no row stars drawn"); // The rows don't fill the list without enough families to scroll, and then there is no bar. if families as f32 * look.row > area.height() { @@ -62,6 +64,13 @@ fn family(name: &str) -> Row { Row::Family(name.to_string()) } +/// The families the font menu lists that stay listed, in its order: the menu's list without the +/// composite fonts at its top, which tests running alongside define and remove in the process-wide +/// database. +fn lasting_families() -> Vec { + vectorcraft_text::FontDb::global().family_list().iter().filter(|f| !f.starts_with('.')).cloned().collect() +} + #[test] fn recent_fonts_are_first_available_unique_and_expandable() { let names: Vec<_> = ["Alpha", "Bravo", "Charlie"].map(str::to_string).into(); @@ -86,7 +95,7 @@ fn recent_fonts_are_first_available_unique_and_expandable() { #[test] fn recent_section_rebuilds_and_arrows_skip_its_headings() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); if names.len() < 3 { return; } @@ -135,7 +144,7 @@ fn recent_section_rebuilds_and_arrows_skip_its_headings() { #[test] fn recent_fonts_obey_search_and_favorite_filters() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); if names.len() < 3 { return; } @@ -176,7 +185,7 @@ fn recent_fonts_obey_search_and_favorite_filters() { #[test] fn opening_on_another_font_keeps_the_recent_section_visible() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); let Some(current) = names.last() else { return }; let recent = vec![names[0].clone()]; let ctx = egui::Context::default(); @@ -244,7 +253,7 @@ fn the_wheel_steps_one_family_per_notch_and_stops_at_the_ends() { /// others in the database's order, under the favourites (and other) filters. #[test] fn the_wheel_follows_the_menus_order_and_filters() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); if names.len() < 3 { return; } @@ -770,8 +779,9 @@ fn the_variable_filter_lists_only_variable_families() { assert_eq!(frame(&mut st, vec![], &favorites).0, [TWO_AXIS_FAMILY]); st.favorites_only = false; // Off again: every family is listed. + let lasting = lasting_families(); let again = click(&mut st, &favorites); assert!(!st.variable_only); - assert_eq!(again, frame(&mut st, vec![], &[]).0); + assert!(lasting.iter().all(|f| has(&again, f)), "{again:?}"); assert!(has(&again, TWO_AXIS_FAMILY) && has(&again, "Source Sans 3") && has(&again, "JetBrains Mono"), "{again:?}"); } diff --git a/crates/ui-egui/src/tests/nativemenu.rs b/crates/ui-egui/src/tests/nativemenu.rs index 65bd01999d..a5a8ea0d7a 100644 --- a/crates/ui-egui/src/tests/nativemenu.rs +++ b/crates/ui-egui/src/tests/nativemenu.rs @@ -69,6 +69,25 @@ fn find<'a>(bar: &'a MenuBar, command: &str) -> &'a native_menu::Item { bar.items().into_iter().find(|it| it.command == Some(command)).unwrap_or_else(|| panic!("no {command} item")) } +/// Is the font or plug-in that an item runs `command` with `params` for gone from the process-wide +/// font database or plug-in registry? A test running alongside can remove a composite font or a +/// plug-in, and the menus then drop its item. +fn source_gone(command: Option<&str>, params: &Value) -> bool { + use vectorcraft_plugins::{PREFIX, effect, export, registry}; + let field = |key| params.get(key).and_then(Value::as_str); + let plugin_gone = |id: Option<&str>| id.is_some_and(|id| registry::get(id).is_none()); + match command { + Some("text.setStyle") => field("font").is_some_and(|f| !vectorcraft_text::FontDb::global().menu_family_list_now().iter().any(|x| x == f)), + Some("plugin.dialog") => plugin_gone(field("id")), + Some("file.openWith") => plugin_gone(field("importer").and_then(export::plugin_id)), + Some("effect.apply" | "effect.dialog") => plugin_gone(field("effect").and_then(effect::plugin_id)), + Some(c) if vectorcraft_engine::cmd::plugin::is_command_id(c) => { + plugin_gone(c.strip_prefix(PREFIX).and_then(|rest| rest.rsplit_once('.')).map(|(id, _)| id)) + } + _ => false, + } +} + #[test] fn vectorcraft_gets_a_mac_app_menu() { let bar = bar(&app(true)); @@ -722,9 +741,13 @@ fn the_menu_bar_is_inert_while_a_file_dialog_is_open() { m.frame(vec![]); m.frame(vec![]); assert!(!m.app.file_dialog_open()); - // Every row from before is back as it was (tests running alongside may add plug-in rows). + // Every row from before is back as it was. Tests running alongside may add rows, and may + // remove the composite font or plug-in a row is for. let after = rows(&m.fake.last()); - assert!(rows(&before).iter().all(|r| after.contains(r)), "the menus are as before"); + for r in rows(&before) { + let listed = after.iter().any(|a| (a.0, &a.1) == (r.0, &r.1)); + assert!(after.contains(&r) || (!listed && source_gone(r.0, &r.1)), "{r:?} is not as before"); + } m.fake.report(native_menu::Event::Click(rulers.1)); m.frame(vec![]); assert_eq!(m.app.ui.view.rulers, !rulers.0); From 72cf92e5403ca84df4ae9572464bd77cf40586e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:00:26 -0800 Subject: [PATCH 36/79] Document Info: Spot Color Objects lists colorized images and skips clear gradients A grayscale or 1-bit image colorized with a spot color now counts as a Spot Color Object. A gradient whose stops are all at an opacity of 0 draws nothing, so its spot stops no longer list the object. --- crates/engine/src/cmd/docinfo.rs | 62 +++++++++++++++++++++++++++++--- docs/mcp.md | 5 +-- docs/target-app-parity.md | 2 +- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 3d2f554dc8..4ed63e7ec1 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in a gradient's stops, at a tint above 0, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", has_doc, info ), @@ -289,8 +289,8 @@ fn spot_swatches(d: &Document) -> HashSet<&str> { /// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is /// linked to, or those a gradient's stops are linked to, each once, in the order of the stops. A -/// link at a tint of 0 prints no ink, and a color that only looks like a spot color is not linked -/// to one. +/// link at a tint of 0 prints no ink, a gradient whose stops are all at an opacity of 0 draws +/// nothing, and a color that only looks like a spot color is not linked to one. fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec { let mut out: Vec = vec![]; let mut add = |swatch: &Option, tint: f32| { @@ -304,7 +304,7 @@ fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec { }; match p { Paint::Solid { swatch, tint, .. } => add(swatch, *tint), - Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| add(&s.swatch, s.tint)), + Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity > 0.0) => g.gradient.stops.iter().for_each(|s| add(&s.swatch, s.tint)), _ => {} } out @@ -523,6 +523,18 @@ fn info(s: &mut Session, p: &Value) -> Result { } } } + // A grayscale or 1-bit image colorized with a spot color prints it, while its pixels take + // the color, as the renderer draws it. + if let NodeKind::Image(im) = &n.kind + && let Some(colorize) = n.extras().colorize.as_deref() + && d.image_blob(im).is_some_and(|b| b.takes_colorize()) + { + for name in spot_label(colorize) { + if !used_spots.contains(&name) { + used_spots.insert(0, name); + } + } + } // Objects counts the objects that Spot Color Objects, Pattern Objects and Gradient Objects list. if !used_spots.is_empty() { let name = n.display_name(); @@ -1174,6 +1186,48 @@ mod tests { assert!(!text.contains("Spare") && !text.contains("Look"), "{text}"); } + /// Spot Color Objects lists a grayscale image colorized with a spot color, and leaves out a + /// gradient whose stops are all at an opacity of 0, which draws nothing. + #[test] + fn spot_color_objects_take_colorized_images_and_skip_clear_gradients() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"model": "cmyk", "c": 0, "m": 1, "y": 1, "k": 0}, "spot": true})).unwrap(); + let mut bytes = Vec::new(); + image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()) + .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png) + .unwrap(); + let image = s + .edit("Place", |d, sel| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(bytes)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "gray".into(), + width: 2, + height: 1, + xf: Default::default(), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + assert!(s.doc().unwrap().doc.node(image).unwrap().extras().colorize.is_some()); + let clear = s.execute("shape.rectangle", &json!({"x": 0, "y": 50, "width": 10, "height": 10})).unwrap()["id"].clone(); + let stops = + json!([{"offset": 0, "color": "#000", "swatch": "Ink", "opacity": 0}, {"offset": 1, "color": "#fff", "swatch": "Ink", "opacity": 0}]); + s.execute("paint.setFill", &json!({"ids": [clear], "gradient": {"kind": "linear", "stops": stops}})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [clear], "none": true})).unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + let name = s.doc().unwrap().doc.node(image).unwrap().display_name(); + assert_eq!(i["spotObjects"], json!([{"id": image.0, "name": name, "spotColors": ["Ink"]}]), "{i}"); + assert_eq!(i["objects"]["spots"].as_u64(), Some(1), "{}", i["objects"]); + } + /// Document Info › Objects counts an object with a gradient or a pattern on both its fill and /// its stroke once, and with Selection Only a selection that holds a group and an object inside /// it counts that object once. diff --git a/docs/mcp.md b/docs/mcp.md index 05d6951cd2..707435b07c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6095,8 +6095,9 @@ stroked with a gradient, by the same rules as `patternObjects`, with the gradien swatch a gradient is linked to, else `Linear gradient`, `Radial gradient` or `Freeform gradient`; the gradients category lists them as Gradient Objects and `objects.gradients` counts them; gradient swatches no art uses are not listed; with `selectionOnly`, those in the selection), `gradientNames` (the gradients those objects use), `spotObjects` -(`[{id, name, spotColors}]`: every object filled or stroked with a spot color swatch, as a solid color or in a -gradient's stops, at a tint above 0, by the same rules as `patternObjects`, with the spot colors it uses; a process +(`[{id, name, spotColors}]`: every object filled or stroked with a spot color swatch, as a solid color or in the +stops of a gradient with a stop above opacity 0, at a tint above 0, and every grayscale image colorized with one, by +the same rules as `patternObjects`, with the spot colors it uses; a process color, even one of the same color, and Registration are not spot colors; the spotColors category lists them as Spot Color Objects and `objects.spots` counts them; spot swatches no art uses are not listed; with `selectionOnly`, those in the selection), `spotColorNames` and `spotColors` (the spot colors those objects use) and `pathLength` (pt: the total length of the paths, compound paths' pieces included and guides left diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 900787b283..d60a1a6c0e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1352,7 +1352,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Document Info has a Repeat Objects category after Embedded Images. It lists each radial, grid and mirror Repeat object by name with its kind and the number of instances it makes. For a grid it gives the rows and columns instead, followed by the number of instances it draws when that is fewer. With Selection Only it lists the Repeat objects in the selection. The Document Info report and Package's report list them too (`document.info {category: "repeats"}`, `repeatObjects`). Document Info's Pattern Objects category lists the objects whose fill or stroke is a pattern, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes, each by name with the patterns it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "patterns"}`, `patternObjects`). Listing an object with a pattern fill and a pattern stroke once, listing objects inside groups and type objects whose characters use a pattern, and leaving out hidden fills and strokes, strokes with no width, guides, a compound path member's own fills and strokes, an image's fills and strokes without a shape, characters the type does not draw and patterns no art uses, and counting the listed objects in Objects, are VectorCraft's own rules. Test: `cmd::docinfo::tests::info_lists_the_pattern_objects` (vectorcraft-engine). Document Info's Gradient Objects category lists the objects whose fill or stroke is a gradient, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as the count of objects with gradient fills or strokes. Each object is listed by name with the gradients it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "gradients"}`, `gradientObjects`). Naming a linked gradient by its swatch and any other gradient by its kind, and leaving out paint that draws nothing by the rules Pattern Objects follows, gradient swatches no art uses, gradient meshes and gradients drawn by effects, are VectorCraft's own rules. Objects counts the listed objects as Gradient Objects. Test: `cmd::docinfo::tests::info_lists_the_gradient_objects` (vectorcraft-engine). - Document Info's Spot Color Objects category lists the objects that use spot colors, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as objects using spot color separations. Each object is listed by name with the spot colors it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "spotColors"}`, `spotObjects`). Taking a solid color or a gradient stop linked to a spot swatch at a tint above 0, leaving out process colors that look the same, Registration, paint that draws nothing by the rules Pattern Objects follows, spot swatches no art uses and spot colors inside patterns, symbols, opacity masks, effects and placed files, are VectorCraft's own rules. Objects counts the listed objects as Spot Color Objects. Test: `cmd::docinfo::tests::info_lists_the_spot_color_objects` (vectorcraft-engine). + Document Info's Spot Color Objects category lists the objects that use spot colors, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as objects using spot color separations. Each object is listed by name with the spot colors it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "spotColors"}`, `spotObjects`). Taking a solid color or a gradient stop linked to a spot swatch at a tint above 0 and a grayscale image colorized with one, leaving out process colors that look the same, Registration, gradients whose stops are all at an opacity of 0, paint that draws nothing by the rules Pattern Objects follows, spot swatches no art uses and spot colors inside patterns, symbols, opacity masks, effects and placed files, are VectorCraft's own rules. Objects counts the listed objects as Spot Color Objects. Tests: `cmd::docinfo::tests::info_lists_the_spot_color_objects` and `spot_color_objects_take_colorized_images_and_skip_clear_gradients` (vectorcraft-engine). Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. From 5a883996775979403374f8507eaa933ffe9a7c2e Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:30:51 +0430 Subject: [PATCH 37/79] Normalize Windows verbatim prefix in script path regression --- crates/engine/src/tests/script_io.rs | 2 +- docs/development.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs index 667635356c..e056ea2f7c 100644 --- a/crates/engine/src/tests/script_io.rs +++ b/crates/engine/src/tests/script_io.rs @@ -230,7 +230,7 @@ fn script_file_link_escape_refused() { /// not matter, and outside it where case matters, whether or not the file is there. #[test] fn script_file_case_variant_folder() { - let base = folder("case").canonicalize().unwrap(); + let base = crate::cmd::findfiles::plain(folder("case").canonicalize().unwrap()); write(&base.join("main/own.txt"), b"own"); let upper = base.join("MAIN"); let source = |name: &str| { diff --git a/docs/development.md b/docs/development.md index 07c0f65a77..79b10e1593 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,6 @@ # Development -> **Last updated:** 2026-11-12 · **Change:** Tests canonicalize temporary roots before comparing preset refusal paths or probing a case-variant script folder (#6860); production access checks unchanged. +> **Last updated:** 2026-11-12 · **Change:** Tests canonicalize temporary roots and normalize Windows verbatim prefixes before comparing preset refusal paths or probing a case-variant script folder (#6860); production access checks unchanged. > **Last updated:** 2026-11-06 · **Change:** Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending. > **Last updated:** 2026-11-01 · **Change:** Linux: Automatic Linux Display Server opens the window through XWayland when a pen is attached on a Wayland session, so the pen draws (#4967); `VECTORCRAFT_NATIVE_WAYLAND=1` keeps Wayland. > **Last updated:** 2026-10-31 · **Change:** files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance`. From 842f1f1b68e5411e35c51c8089ce146125d9f0f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:01:14 -0800 Subject: [PATCH 38/79] EPS import: CIDFonts only named, over a predefined CJK CMap, set their characters as live type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type in a font found by name with a predefined non-Unicode CMap, such as `/Ryumin-Light-83pv-RKSJ-H findfont` or a `composefont` with `GBK-EUC-H`, opened as Latin-1 letters (`ú{A` for 日本A) in a font whose style carried the CMap name (`Light-83pv-RKSJ-H`). Only Unicode CMaps were split off the font name, and `composefont` failed with `undefinedresource` for the others. - The predefined CMap decoder used by legacy `.ai` type moves to `import/predefined_cmap.rs`, and EPS import decodes these CMaps' codes with it (Shift-JIS, EUC, GBK, Big Five, UHC and the other public CMaps). - A font name splits at a predefined CMap unless an installed face has the whole name. A CMap that can't be read gives a warning. - Type over a vertical CMap (`-V`) is set as vertical type, and each code moves the point one em down the column, the default `DW2` displacement. - Every show operator sets decoded type with the same spacing and shaping as other fonts found by name. - Decoding a string counts against the import's memory budget. --- crates/engine/tests/import_fuzz.rs | 5 +- crates/eps/src/import.rs | 1 + crates/eps/src/import/ai/legacy_text.rs | 278 +----------------- crates/eps/src/import/cid.rs | 32 +- crates/eps/src/import/interp.rs | 4 + crates/eps/src/import/predefined_cmap.rs | 358 +++++++++++++++++++++++ crates/eps/src/import/tests_cid.rs | 165 +++++++++++ crates/eps/src/import/text.rs | 261 ++++++++++++----- 8 files changed, 729 insertions(+), 375 deletions(-) create mode 100644 crates/eps/src/import/predefined_cmap.rs diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 67bb04c33b..ac98db8e9c 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2682,7 +2682,10 @@ fn arb_ps_token() -> impl Strategy { // Local VM restored: dictionary changes undone, saves restored twice. "/a 1 def save /a 2 def", "restore", "save dup restore", "1 dict save exch /k 1 put", "cachestatus", "/n 0 def save /n n 1 add store", // Executable strings and keys other than names (PLRM 3rd ed., `cvx`, `load`). - "(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit" + "(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit", + // CID-keyed fonts found by name over predefined CMaps, horizontal and vertical. + "/Ryumin-Light-90ms-RKSJ-H", "/Ryumin-Light-EUC-V", "/Ryumin-Light-H", "/GBT-EUC-H", "/KSCms-UHC-V", "/UniJIS-UTF16-V", + "[/Ryumin-Light]", "composefont", "<93fa967b8540fd0d41>", "" ]) .prop_map(str::to_string), ] diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 0a5799ddf9..dc335bf1a1 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -42,6 +42,7 @@ mod interp; mod lex; mod native; mod obj; +mod predefined_cmap; mod preview; mod shading; mod text; diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index a1b24662ec..6cdbb96747 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -14,12 +14,12 @@ use std::sync::Arc; -use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded}; use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen}; use vectorcraft_color::Paint; use vectorcraft_doc::{Burasagari, CharStyle, Justify, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject, TextRun}; use vectorcraft_geom::AnchorKind; +use super::super::super::predefined_cmap::{self, CMapCodes, composite_font}; use super::super::obj::V; use super::art::path_data; use super::{FLIP, GState, HashSet, Kind, Node, NodeId, Reader}; @@ -284,259 +284,15 @@ impl TypeState { /// How strings in a font with predefined CMap `cmap` decode; `None` when the CMap can't be read. fn cmap_codes(&mut self, cmap: &str) -> Option> { - if let Some(form) = unicode_cmap(cmap) { - return Some(Arc::new(CMapCodes::Unicode(form))); - } if let Some((_, codes)) = self.cmaps.iter().find(|(n, _)| n == cmap) { return codes.clone(); } - let codes = cid_codes(cmap).map(Arc::new); + let codes = predefined_cmap::codes(cmap).map(Arc::new); self.cmaps.push((cmap.to_string(), codes.clone())); codes } } -/// How a composite font's strings decode. -#[derive(Debug)] -enum CMapCodes { - /// By a predefined CMap's codes to CIDs, then by its character collection's CID → Unicode CMap; - /// with `high_bit`, each byte 0x21 to 0x7E gets its high bit set first. `space` is the CMap's - /// code space ([`code_space`]). - Cids { codes: Box, unicode: Box, high_bit: bool, space: Option }, - /// A predefined Unicode CMap: the codes are the characters. - Unicode(Unicode), -} - -/// A CMap's code space: ranges of codes, each given by its lowest and highest code, both with the -/// code's length in bytes. -type CodeSpace = &'static [(&'static [u8], &'static [u8])]; - -/// What control byte `b` is in a composite font's string: `\r` a paragraph break, `\t` a tab. -fn control(b: u8) -> Option { - match b { - b'\r' => Some('\n'), - b'\t' => Some('\t'), - _ => None, - } -} - -impl CMapCodes { - /// The characters of string `bytes`, leaving out codes the CMaps don't map; a `\r` is a - /// paragraph break and a `\t` a tab (also as a byte left over after the last whole code of a - /// UTF-16 or UTF-32 string). - fn decode(&self, bytes: &[u8]) -> String { - let (codes, unicode, high_bit, space) = match self { - Self::Unicode(form) => { - let unit = match form { - Unicode::Utf16 => 2, - Unicode::Utf8 => 1, - Unicode::Utf32 => 4, - }; - let (whole, rest) = bytes.split_at_checked(bytes.len() - bytes.len() % unit).unwrap_or((bytes, &[])); - let mut text = unicode_text(whole, *form).replace('\r', "\n"); - text.extend(rest.last().copied().and_then(control)); - return text; - } - Self::Cids { codes, unicode, high_bit, space } => (codes, unicode, *high_bit, space), - }; - let euc: Vec; - let bytes = if high_bit { - euc = bytes.iter().map(|&b| if (0x21..0x7f).contains(&b) { b | 0x80 } else { b }).collect(); - euc.as_slice() - } else { - bytes - }; - let mut out = String::new(); - let mut at = 0; - while let Some(&first) = bytes.get(at) { - // VectorCraft reads a 0x0D byte as a paragraph break and a 0x09 byte as a tab in every - // CMap, as in other fonts' strings, including the row-and-cell CMaps, whose code space - // has no one-byte codes. - if let Some(control) = control(first) { - out.push(control); - at += 1; - continue; - } - let rest = bytes.get(at..).unwrap_or_default(); - let code = |len: u8| rest.get(..usize::from(len)).map(|b| b.iter().fold(0u32, |c, &b| c << 8 | u32::from(b))); - let found = match space { - // The code is as long as the code space range it falls in; a byte that starts no - // code in the code space is skipped. - Some(space) => { - let Some(len) = code_len(space, rest) else { - at += 1; - continue; - }; - code(len).map(|c| (len, codes.lookup_cid_code(c, len))) - } - None => (1..=4u8).find_map(|len| Some((len, Some(codes.lookup_cid_code(code(len)?, len)?)))), - }; - let Some((len, cid)) = found else { - at += 1; - continue; - }; - at += usize::from(len); - // A code the CMap leaves out is dropped. - let Some(cid) = cid else { continue }; - match unicode.lookup_bf_string(cid) { - // A one-byte Roman code whose CID the table gives as a combining mark alone (0x7E in - // the Mac Shift-JIS CMaps, U+0303): the ASCII character, as Adobe's cid2code.txt lists first. - Some(BfString::Char('\u{300}'..='\u{36f}')) if len == 1 && (0x20..0x7f).contains(&first) => out.push(char::from(first)), - Some(BfString::Char(c)) if c != char::REPLACEMENT_CHARACTER => out.push(c), - Some(BfString::String(s)) => out.extend(s.chars().filter(|c| *c != char::REPLACEMENT_CHARACTER)), - _ => {} - } - } - out - } -} - -/// How a predefined Unicode CMap's codes encode their characters. -#[derive(Clone, Copy, PartialEq, Debug)] -enum Unicode { - /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first. - Utf16, - Utf8, - /// Four bytes, the high byte first. - Utf32, -} - -/// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`, -/// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V` -/// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: ). -fn unicode_cmap(name: &str) -> Option { - let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?; - let rest = rest.strip_suffix("-HW").unwrap_or(rest); - let (collection, form) = rest.rsplit_once('-')?; - let collections = ["UniJIS", "UniJISPro", "UniJIS2004", "UniJISX0213", "UniJISX02132004", "UniHojo", "UniGB", "UniCNS", "UniKS", "UniAKR"]; - if !collections.contains(&collection) { - return None; - } - match form { - "UCS2" | "UTF16" => Some(Unicode::Utf16), - "UTF8" => Some(Unicode::Utf8), - "UTF32" => Some(Unicode::Utf32), - _ => None, - } -} - -/// The characters string `s` encodes in Unicode form `form` (invalid codes left out). -fn unicode_text(s: &[u8], form: Unicode) -> String { - match form { - Unicode::Utf16 => { - let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b)); - char::decode_utf16(units).filter_map(Result::ok).collect() - } - Unicode::Utf8 => s.utf8_chunks().map(|chunk| chunk.valid()).collect(), - Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(), - } -} - -/// Predefined CMap `name` among those VectorCraft carries, leaving out the Identity CMaps, whose -/// codes are CIDs of no known character collection. -fn predefined(name: &str) -> Option> { - match CMapName::from_bytes(name.as_bytes()) { - CMapName::Custom(_) | CMapName::IdentityH | CMapName::IdentityV => None, - name => Some(name), - } -} - -/// Adobe's predefined CMaps that VectorCraft doesn't carry, by name without `-H` or `-V` -/// (): the nearest carried CMap with the same -/// code space, and whether each byte of a row-and-cell code gets its high bit set first (the EUC -/// form of the same code; `GB-H`, `CNS1-H` and `KSC-H` have no one-byte codes, their code space -/// is `<2121>` to `<7E7E>`); `None` for those no carried CMap reads. -fn stand_in(stem: &str) -> Option> { - Some(match stem { - "RKSJ" | "78-RKSJ" | "78ms-RKSJ" => Some(("90ms-RKSJ-H", false)), - "78-EUC" => Some(("EUC-H", false)), - "78" | "Add" | "Ext" | "NWP" => Some(("H", false)), - "GB" => Some(("GB-EUC-H", true)), - "CNS1" => Some(("CNS-EUC-H", true)), - "KSC" => Some(("KSC-EUC-H", true)), - "B5" => Some(("ETen-B5-H", false)), - "ETHK-B5" | "HKdla-B5" | "HKdlb-B5" | "HKgccs-B5" | "HKm314-B5" | "HKm471-B5" => Some(("HKscs-B5-H", false)), - "GBT" | "GBT-EUC" | "GBTpc-EUC" | "CNS2" | "KSC-Johab" => None, - _ => return None, - }) -} - -/// Predefined CMap `cmap`'s codes to CIDs, and its character collection's CID → Unicode CMap. A -/// vertical CMap's codes are read with its horizontal CMap when VectorCraft carries that: the two -/// differ only in the CIDs of vertical glyph forms. A CMap VectorCraft doesn't carry is read with -/// its [`stand_in`]. -fn cid_codes(cmap: &str) -> Option { - let read = |name: &str| CMap::parse(load_embedded(predefined(name)?)?, load_embedded); - let horizontal = cmap.strip_suffix('V').map(|stem| format!("{stem}H")); - let stem = cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")); - let (codes, high_bit) = horizontal.iter().map(String::as_str).chain([cmap]).find_map(|n| Some((read(n)?, false))).or_else(|| { - let (name, high_bit) = stand_in(stem?).flatten()?; - Some((read(name)?, high_bit)) - })?; - let table = codes.metadata().character_collection.as_ref()?.family.ucs2_cmap()?; - let unicode = CMap::parse(load_embedded(table)?, load_embedded)?; - // A row-and-cell code read in its EUC form falls in the EUC CMap's code space. - let space = if high_bit { stand_in(stem?).flatten().and_then(|(name, _)| code_space(name)) } else { code_space(cmap) }; - Some(CMapCodes::Cids { codes: Box::new(codes), unicode: Box::new(unicode), high_bit, space }) -} - -/// The code space of predefined CMap `cmap`, as Adobe's CMap files give it -/// (); a CMap's vertical form has its -/// horizontal form's. `None` for a CMap not listed here. -fn code_space(cmap: &str) -> Option { - const RKSJ: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; - const RKSJ_MAC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\xfd", b"\xff"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; - const EUC_JP: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\xa0", b"\x8e\xdf"), (b"\xa1\xa1", b"\xfe\xfe")]; - const ROW_CELL: CodeSpace = &[(b"\x21\x21", b"\x7e\x7e")]; - const EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe")]; - const GB_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\xa1", b"\xfc\xfe")]; - const GBK: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x40", b"\xfe\xfe")]; - const GBK2K: CodeSpace = &[(b"\x00", b"\x7f"), (b"\x81\x40", b"\xfe\xfe"), (b"\x81\x30\x81\x30", b"\xfe\x39\xfe\x39")]; - const B5_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\x40", b"\xfc\xfe")]; - const B5: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\x40", b"\xfe\xfe")]; - const HKSCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x87\x40", b"\xfe\xfe")]; - const HKDLB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\x40", b"\xfe\xfe")]; - const HKGCCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8a\x40", b"\xfe\xfe")]; - const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")]; - const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")]; - const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")]; - let stem = match cmap { - "H" | "V" => "", - _ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?, - }; - let stem = stem.strip_suffix("-HW").unwrap_or(stem); - Some(match stem { - "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC, - _ if stem.ends_with("RKSJ") => RKSJ, - "EUC" | "78-EUC" => EUC_JP, - "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL, - "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC, - "GBpc-EUC" | "GBTpc-EUC" => GB_PC, - "GBK-EUC" | "GBKp-EUC" => GBK, - "GBK2K" => GBK2K, - "B5pc" => B5_PC, - "B5" | "ETen-B5" | "ETenms-B5" | "HKdla-B5" | "HKm314-B5" | "HKm471-B5" => B5, - "HKscs-B5" | "ETHK-B5" => HKSCS, - "HKdlb-B5" => HKDLB, - "HKgccs-B5" => HKGCCS, - "CNS-EUC" => CNS_EUC, - "KSCpc-EUC" => KSC_PC, - "KSCms-UHC" => UHC, - _ => return None, - }) -} - -/// The length of the code string `bytes` starts with: the length of the code space range whose -/// lowest and highest codes hold each of its bytes between them. `None` when no range does. -fn code_len(space: CodeSpace, bytes: &[u8]) -> Option { - space - .iter() - .filter(|(lo, hi)| bytes.get(..lo.len()).is_some_and(|b| b.iter().zip(lo.iter().zip(hi.iter())).all(|(b, (lo, hi))| (lo..=hi).contains(&b)))) - .map(|(lo, _)| lo.len()) - .min() - .and_then(|len| u8::try_from(len).ok()) -} - /// Encoding pairs (§3.3.1): codes, each followed by the glyph names from it on, into `table`. fn encoding_pairs(table: &mut [Option], vals: &[V]) { let mut code: Option = None; @@ -556,36 +312,6 @@ fn encoding_pairs(table: &mut [Option], vals: &[V]) { } } -/// A composite font's name `base-CMap`: the base font's name and its CMap's name, when that is a -/// predefined Unicode CMap ([`unicode_cmap`]), one of the predefined CMaps VectorCraft carries -/// ([`predefined`]) or one of the others ([`stand_in`]), in its horizontal (`-H`) or vertical -/// (`-V`) form. A name ending in an Identity CMap is not read as one, nor is one that ends in an -/// unknown CMap name with an encoding part (`UniFoo-UTF8-H`), which would otherwise end in `H`. -fn composite_font(name: &str) -> Option<(&str, &str)> { - if !(name.ends_with("-H") || name.ends_with("-V")) { - return None; - } - // The longest CMap name first: `83pv-RKSJ-H` before `H`. - // No predefined CMap name is longer than 32 bytes. - for (at, _) in name.match_indices('-').filter(|(at, _)| name.len() - at <= 33) { - let (Some(base), Some(cmap)) = (name.get(..at).filter(|b| !b.is_empty()), name.get(at + 1..)) else { continue }; - if matches!(cmap, "Identity-H" | "Identity-V") { - return None; - } - let encoding_part = - base.rsplit('-').next().is_some_and(|part| ["UCS2", "UTF8", "UTF16", "UTF32", "HW", "EUC", "RKSJ", "B5", "UHC", "Johab"].contains(&part)); - if matches!(cmap, "H" | "V") && encoding_part { - return None; - } - let vertical_of_predefined = || cmap.strip_suffix('V').is_some_and(|stem| predefined(&format!("{stem}H")).is_some()); - let other = || cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")).is_some_and(|stem| stand_in(stem).is_some()); - if unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other() { - return Some((base, cmap)); - } - } - None -} - /// An operator's `N` operands, the last `N` values before it, when each is a finite number. fn nums(vals: &[V]) -> Option<[f64; N]> { let operands = vals.get(vals.len().checked_sub(N)?..)?; diff --git a/crates/eps/src/import/cid.rs b/crates/eps/src/import/cid.rs index e2ad169f79..a54c11ae2b 100644 --- a/crates/eps/src/import/cid.rs +++ b/crates/eps/src/import/cid.rs @@ -18,6 +18,7 @@ use vectorcraft_geom::BezPath; use super::interp::{Interp, matrix_obj}; use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err}; +use super::predefined_cmap::Unicode; /// Most codespace ranges, character and notdef mappings a CMap may hold. const MAX_RANGES: usize = 1 << 16; @@ -210,16 +211,6 @@ fn number(data: &[u8], at: usize, n: usize) -> Option { (n <= 8).then(|| bytes.iter().fold(0u64, |v, b| v << 8 | u64::from(*b))) } -/// How a predefined Unicode CMap's codes encode their characters. -#[derive(Clone, Copy, PartialEq, Debug)] -pub(super) enum Unicode { - /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first. - Utf16, - Utf8, - /// Four bytes, the high byte first. - Utf32, -} - /// The encoding of predefined Unicode CMap `name` and whether it is vertical: one of Adobe's /// public CMap resources whose name says its codes are Unicode: `Uni` and a character /// collection, then `-UCS2`, `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), @@ -246,18 +237,6 @@ pub(super) fn unicode_cmap(name: &str) -> Option<(Unicode, bool)> { Some((form, vertical)) } -/// The characters show string `s` encodes in Unicode form `form` (invalid codes left out). -pub(super) fn unicode_text(s: &[u8], form: Unicode) -> String { - match form { - Unicode::Utf16 => { - let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b)); - char::decode_utf16(units).filter_map(Result::ok).collect() - } - Unicode::Utf8 => String::from_utf8_lossy(s).chars().filter(|c| *c != char::REPLACEMENT_CHARACTER).collect(), - Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(), - } -} - /// A predefined Identity CMap: two-byte codes, each the CID of its own value; vertical for /// `Identity-V`. pub(super) fn identity(name: &str, vertical: bool) -> Obj { @@ -417,8 +396,13 @@ impl Interp<'_> { return Ok(d.clone()); } let text = name.text().map(|t| t.to_string()).unwrap_or_default(); - match unicode_cmap(&text) { - Some((_, vertical)) => { + // Another predefined CMap the file doesn't define, without its mappings: its codes are + // read by its name when type is set (see `text`). + let predefined = unicode_cmap(&text) + .map(|(_, vertical)| vertical) + .or_else(|| super::predefined_cmap::known(&text).then(|| super::predefined_cmap::vertical(&text))); + match predefined { + Some(vertical) => { let mut d = Dict::new(); d.insert(Key::name("CMapName"), Obj::name(&text)); d.insert(Key::name("CMapType"), Obj::Int(1)); diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 992a8bbcea..58708bb97e 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -92,6 +92,9 @@ pub(crate) struct Interp<'a> { pub cid_subrs: super::cid::CidSubrs, /// The font number `usefont` gave the CMap mappings that follow. pub cmap_font: i64, + /// The predefined CMaps type was set in so far, by name, with how their strings decode + /// (`None` when one can't be read; see `text`). + pub predefined_cmaps: Vec<(Rc, Option>)>, pub g: GState, pub saved: Vec, pub out: Out, @@ -232,6 +235,7 @@ impl<'a> Interp<'a> { cff: vec![], cid_subrs: vec![], cmap_font: 0, + predefined_cmaps: vec![], type42_counted: HashSet::new(), g, saved: vec![], diff --git a/crates/eps/src/import/predefined_cmap.rs b/crates/eps/src/import/predefined_cmap.rs new file mode 100644 index 0000000000..1a9b3b95be --- /dev/null +++ b/crates/eps/src/import/predefined_cmap.rs @@ -0,0 +1,358 @@ +//! Strings in fonts over Adobe's predefined CMaps () +//! read as characters: the codes of a predefined Unicode CMap are the characters; those of the +//! others map to CIDs of a character collection, whose CID → Unicode CMap gives the characters. +//! Used by legacy `.ai` type in composite fonts and by type in CID-keyed fonts found by name. + +use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded}; + +/// How a composite font's strings decode. +#[derive(Debug)] +pub(super) enum CMapCodes { + /// By a predefined CMap's codes to CIDs, then by its character collection's CID → Unicode CMap; + /// with `high_bit`, each byte 0x21 to 0x7E gets its high bit set first. `space` is the CMap's + /// code space ([`code_space`]). + Cids { codes: Box, unicode: Box, high_bit: bool, space: Option }, + /// A predefined Unicode CMap: the codes are the characters. + Unicode(Unicode), +} + +/// A CMap's code space: ranges of codes, each given by its lowest and highest code, both with the +/// code's length in bytes. +type CodeSpace = &'static [(&'static [u8], &'static [u8])]; + +/// What control byte `b` is in a composite font's string: `\r` a paragraph break, `\t` a tab. +fn control(b: u8) -> Option { + match b { + b'\r' => Some('\n'), + b'\t' => Some('\t'), + _ => None, + } +} + +impl CMapCodes { + /// The characters of string `bytes`, leaving out codes the CMaps don't map; a `\r` is a + /// paragraph break and a `\t` a tab (also as a byte left over after the last whole code of a + /// UTF-16 or UTF-32 string). + pub(super) fn decode(&self, bytes: &[u8]) -> String { + let mut text = String::new(); + self.each_code(bytes, |_, chars| text.extend(chars)); + text + } + + /// The codes of string `bytes`, each with its value and the characters it stands for (as + /// [`Self::decode`] reads them; none for a code the CMaps don't map, or for a byte that starts + /// no code, which counts as a code of its own). + pub(super) fn codes(&self, bytes: &[u8]) -> Decoded { + let mut out = Decoded::default(); + self.each_code(bytes, |code, chars| out.push(code, chars)); + out + } + + /// Calls `emit` with each code of string `bytes` in turn ([`Self::codes`]): its value and the + /// characters it stands for. + fn each_code(&self, bytes: &[u8], mut emit: impl FnMut(u32, &mut dyn Iterator)) { + let value = |b: &[u8]| b.iter().fold(0u32, |c, &b| c << 8 | u32::from(b)); + let (codes, unicode, high_bit, space) = match self { + Self::Unicode(form) => { + let unit = match form { + Unicode::Utf16 => 2, + Unicode::Utf8 => 1, + Unicode::Utf32 => 4, + }; + let (whole, rest) = bytes.split_at_checked(bytes.len() - bytes.len() % unit).unwrap_or((bytes, &[])); + unicode_codes(whole, *form, |code, c| emit(value(code), &mut c.map(|c| if c == '\r' { '\n' } else { c }).into_iter())); + if let Some(&last) = rest.last() { + emit(value(rest), &mut control(last).into_iter()); + } + return; + } + Self::Cids { codes, unicode, high_bit, space } => (codes, unicode, *high_bit, space), + }; + // A code's value is that of the string's bytes, also where they're read in their EUC form. + let original = bytes; + let value_at = |at: usize, len: usize| original.get(at..at + len).map_or(0, value); + let euc: Vec; + let bytes = if high_bit { + euc = bytes.iter().map(|&b| if (0x21..0x7f).contains(&b) { b | 0x80 } else { b }).collect(); + euc.as_slice() + } else { + bytes + }; + let mut at = 0; + while let Some(&first) = bytes.get(at) { + // VectorCraft reads a 0x0D byte as a paragraph break and a 0x09 byte as a tab in every + // CMap, as in other fonts' strings, including the row-and-cell CMaps, whose code space + // has no one-byte codes. + if let Some(control) = control(first) { + emit(value_at(at, 1), &mut Some(control).into_iter()); + at += 1; + continue; + } + let rest = bytes.get(at..).unwrap_or_default(); + let code = |len: u8| rest.get(..usize::from(len)).map(value); + let found = match space { + // The code is as long as the code space range it falls in; a byte that starts no + // code in the code space is skipped. + Some(space) => code_len(space, rest).and_then(|len| code(len).map(|c| (len, codes.lookup_cid_code(c, len)))), + None => (1..=4u8).find_map(|len| Some((len, Some(codes.lookup_cid_code(code(len)?, len)?)))), + }; + let Some((len, cid)) = found else { + emit(value_at(at, 1), &mut None.into_iter()); + at += 1; + continue; + }; + let value = value_at(at, usize::from(len)); + at += usize::from(len); + // A code the CMap leaves out stands for no character. + match cid.and_then(|cid| unicode.lookup_bf_string(cid)) { + // A one-byte Roman code whose CID the table gives as a combining mark alone (0x7E in + // the Mac Shift-JIS CMaps, U+0303): the ASCII character, as Adobe's cid2code.txt lists first. + Some(BfString::Char('\u{300}'..='\u{36f}')) if len == 1 && (0x20..0x7f).contains(&first) => { + emit(value, &mut Some(char::from(first)).into_iter()) + } + Some(BfString::Char(c)) if c != char::REPLACEMENT_CHARACTER => emit(value, &mut Some(c).into_iter()), + Some(BfString::String(s)) => emit(value, &mut s.chars().filter(|c| *c != char::REPLACEMENT_CHARACTER)), + _ => emit(value, &mut None.into_iter()), + } + } + } +} + +/// A string's characters, and its codes: each code's value and how many of the characters it +/// stands for, in order. +#[derive(Debug, Default)] +pub(super) struct Decoded { + pub text: String, + pub codes: Vec<(u32, usize)>, +} + +impl Decoded { + /// Code `code`, standing for characters `chars`. + pub(super) fn push(&mut self, code: u32, chars: impl IntoIterator) { + let before = self.text.len(); + self.text.extend(chars); + let n = self.text.get(before..).map_or(0, |added| added.chars().count()); + self.codes.push((code, n)); + } +} + +/// How strings in a font over predefined CMap `cmap` decode; `None` when the CMap can't be read. +pub(super) fn codes(cmap: &str) -> Option { + match unicode_cmap(cmap) { + Some(form) => Some(CMapCodes::Unicode(form)), + None => cid_codes(cmap), + } +} + +/// How a predefined Unicode CMap's codes encode their characters. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(super) enum Unicode { + /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first. + Utf16, + Utf8, + /// Four bytes, the high byte first. + Utf32, +} + +/// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`, +/// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V` +/// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: ). +fn unicode_cmap(name: &str) -> Option { + let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?; + let rest = rest.strip_suffix("-HW").unwrap_or(rest); + let (collection, form) = rest.rsplit_once('-')?; + let collections = ["UniJIS", "UniJISPro", "UniJIS2004", "UniJISX0213", "UniJISX02132004", "UniHojo", "UniGB", "UniCNS", "UniKS", "UniAKR"]; + if !collections.contains(&collection) { + return None; + } + match form { + "UCS2" | "UTF16" => Some(Unicode::Utf16), + "UTF8" => Some(Unicode::Utf8), + "UTF32" => Some(Unicode::Utf32), + _ => None, + } +} + +/// Calls `out` with each code of string `s` in Unicode form `form` in turn, and the character it +/// encodes (none for an invalid code: a lone surrogate, a byte that starts no UTF-8 character, a +/// value past U+10FFFF). +fn unicode_codes(s: &[u8], form: Unicode, mut out: impl FnMut(&[u8], Option)) { + match form { + Unicode::Utf16 => { + let unit = |i: usize| s.get(i..i + 2).and_then(|b| <[u8; 2]>::try_from(b).ok()).map(u16::from_be_bytes); + let mut at = 0; + while let Some(u) = unit(at) { + let next = unit(at + 2); + let pair = next.filter(|n| (0xd800..0xdc00).contains(&u) && (0xdc00..0xe000).contains(n)); + let len = if pair.is_some() { 4 } else { 2 }; + let c = char::decode_utf16([u].into_iter().chain(pair)).next().and_then(Result::ok); + out(s.get(at..at + len).unwrap_or_default(), c); + at += len; + } + } + Unicode::Utf8 => { + let mut at = 0; + for chunk in s.utf8_chunks() { + for c in chunk.valid().chars() { + out(s.get(at..at + c.len_utf8()).unwrap_or_default(), Some(c)); + at += c.len_utf8(); + } + for _ in chunk.invalid() { + out(s.get(at..at + 1).unwrap_or_default(), None); + at += 1; + } + } + } + Unicode::Utf32 => { + for b in s.as_chunks::<4>().0 { + out(b.as_slice(), char::from_u32(u32::from_be_bytes(*b))); + } + } + } +} + +/// Predefined CMap `name` among those VectorCraft carries, leaving out the Identity CMaps, whose +/// codes are CIDs of no known character collection. +fn predefined(name: &str) -> Option> { + match CMapName::from_bytes(name.as_bytes()) { + CMapName::Custom(_) | CMapName::IdentityH | CMapName::IdentityV => None, + name => Some(name), + } +} + +/// Adobe's predefined CMaps that VectorCraft doesn't carry, by name without `-H` or `-V` +/// (): the nearest carried CMap with the same +/// code space, and whether each byte of a row-and-cell code gets its high bit set first (the EUC +/// form of the same code; `GB-H`, `CNS1-H` and `KSC-H` have no one-byte codes, their code space +/// is `<2121>` to `<7E7E>`); `None` for those no carried CMap reads. +fn stand_in(stem: &str) -> Option> { + Some(match stem { + "RKSJ" | "78-RKSJ" | "78ms-RKSJ" => Some(("90ms-RKSJ-H", false)), + "78-EUC" => Some(("EUC-H", false)), + "78" | "Add" | "Ext" | "NWP" => Some(("H", false)), + "GB" => Some(("GB-EUC-H", true)), + "CNS1" => Some(("CNS-EUC-H", true)), + "KSC" => Some(("KSC-EUC-H", true)), + "B5" => Some(("ETen-B5-H", false)), + "ETHK-B5" | "HKdla-B5" | "HKdlb-B5" | "HKgccs-B5" | "HKm314-B5" | "HKm471-B5" => Some(("HKscs-B5-H", false)), + "GBT" | "GBT-EUC" | "GBTpc-EUC" | "CNS2" | "KSC-Johab" => None, + _ => return None, + }) +} + +/// Predefined CMap `cmap`'s codes to CIDs, and its character collection's CID → Unicode CMap. A +/// vertical CMap's codes are read with its horizontal CMap when VectorCraft carries that: the two +/// differ only in the CIDs of vertical glyph forms. A CMap VectorCraft doesn't carry is read with +/// its [`stand_in`]. +fn cid_codes(cmap: &str) -> Option { + let read = |name: &str| CMap::parse(load_embedded(predefined(name)?)?, load_embedded); + let horizontal = cmap.strip_suffix('V').map(|stem| format!("{stem}H")); + let stem = cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")); + let (codes, high_bit) = horizontal.iter().map(String::as_str).chain([cmap]).find_map(|n| Some((read(n)?, false))).or_else(|| { + let (name, high_bit) = stand_in(stem?).flatten()?; + Some((read(name)?, high_bit)) + })?; + let table = codes.metadata().character_collection.as_ref()?.family.ucs2_cmap()?; + let unicode = CMap::parse(load_embedded(table)?, load_embedded)?; + // A row-and-cell code read in its EUC form falls in the EUC CMap's code space. + let space = if high_bit { stand_in(stem?).flatten().and_then(|(name, _)| code_space(name)) } else { code_space(cmap) }; + Some(CMapCodes::Cids { codes: Box::new(codes), unicode: Box::new(unicode), high_bit, space }) +} + +/// The code space of predefined CMap `cmap`, as Adobe's CMap files give it +/// (); a CMap's vertical form has its +/// horizontal form's. `None` for a CMap not listed here. +fn code_space(cmap: &str) -> Option { + const RKSJ: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; + const RKSJ_MAC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\xfd", b"\xff"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; + const EUC_JP: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\xa0", b"\x8e\xdf"), (b"\xa1\xa1", b"\xfe\xfe")]; + const ROW_CELL: CodeSpace = &[(b"\x21\x21", b"\x7e\x7e")]; + const EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe")]; + const GB_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\xa1", b"\xfc\xfe")]; + const GBK: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x40", b"\xfe\xfe")]; + const GBK2K: CodeSpace = &[(b"\x00", b"\x7f"), (b"\x81\x40", b"\xfe\xfe"), (b"\x81\x30\x81\x30", b"\xfe\x39\xfe\x39")]; + const B5_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\x40", b"\xfc\xfe")]; + const B5: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\x40", b"\xfe\xfe")]; + const HKSCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x87\x40", b"\xfe\xfe")]; + const HKDLB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\x40", b"\xfe\xfe")]; + const HKGCCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8a\x40", b"\xfe\xfe")]; + const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")]; + const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")]; + const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")]; + let stem = match cmap { + "H" | "V" => "", + _ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?, + }; + let stem = stem.strip_suffix("-HW").unwrap_or(stem); + Some(match stem { + "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC, + _ if stem.ends_with("RKSJ") => RKSJ, + "EUC" | "78-EUC" => EUC_JP, + "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL, + "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC, + "GBpc-EUC" | "GBTpc-EUC" => GB_PC, + "GBK-EUC" | "GBKp-EUC" => GBK, + "GBK2K" => GBK2K, + "B5pc" => B5_PC, + "B5" | "ETen-B5" | "ETenms-B5" | "HKdla-B5" | "HKm314-B5" | "HKm471-B5" => B5, + "HKscs-B5" | "ETHK-B5" => HKSCS, + "HKdlb-B5" => HKDLB, + "HKgccs-B5" => HKGCCS, + "CNS-EUC" => CNS_EUC, + "KSCpc-EUC" => KSC_PC, + "KSCms-UHC" => UHC, + _ => return None, + }) +} + +/// The length of the code string `bytes` starts with: the length of the code space range whose +/// lowest and highest codes hold each of its bytes between them. `None` when no range does. +fn code_len(space: CodeSpace, bytes: &[u8]) -> Option { + space + .iter() + .filter(|(lo, hi)| bytes.get(..lo.len()).is_some_and(|b| b.iter().zip(lo.iter().zip(hi.iter())).all(|(b, (lo, hi))| (lo..=hi).contains(&b)))) + .map(|(lo, _)| lo.len()) + .min() + .and_then(|len| u8::try_from(len).ok()) +} + +/// Whether `cmap` names a predefined CMap: a predefined Unicode CMap ([`unicode_cmap`]), one of the +/// predefined CMaps VectorCraft carries ([`predefined`]) or one of the others ([`stand_in`]), in +/// its horizontal (`-H`) or vertical (`-V`) form. The Identity CMaps are not among them. +pub(super) fn known(cmap: &str) -> bool { + let vertical_of_predefined = || cmap.strip_suffix('V').is_some_and(|stem| predefined(&format!("{stem}H")).is_some()); + let other = || cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")).is_some_and(|stem| stand_in(stem).is_some()); + unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other() +} + +/// Whether predefined CMap `cmap` is a vertical CMap (`-V`, or the row-and-cell `V`). +pub(super) fn vertical(cmap: &str) -> bool { + cmap == "V" || cmap.ends_with("-V") +} + +/// A composite font's name `base-CMap`: the base font's name and its CMap's name, when that is a +/// predefined CMap ([`known`]). A name ending in an Identity CMap is not read as one, nor is one +/// that ends in an unknown CMap name with an encoding part (`UniFoo-UTF8-H`), which would otherwise +/// end in `H`. +pub(super) fn composite_font(name: &str) -> Option<(&str, &str)> { + if !(name.ends_with("-H") || name.ends_with("-V")) { + return None; + } + // The longest CMap name first: `83pv-RKSJ-H` before `H`. + // No predefined CMap name is longer than 32 bytes. + for (at, _) in name.match_indices('-').filter(|(at, _)| name.len() - at <= 33) { + let (Some(base), Some(cmap)) = (name.get(..at).filter(|b| !b.is_empty()), name.get(at + 1..)) else { continue }; + if matches!(cmap, "Identity-H" | "Identity-V") { + return None; + } + let encoding_part = + base.rsplit('-').next().is_some_and(|part| ["UCS2", "UTF8", "UTF16", "UTF32", "HW", "EUC", "RKSJ", "B5", "UHC", "Johab"].contains(&part)); + if matches!(cmap, "H" | "V") && encoding_part { + return None; + } + if known(cmap) { + return Some((base, cmap)); + } + } + None +} diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index 2b01b2b4f2..9e452c4bf4 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -403,6 +403,171 @@ fn named_cidfonts_over_unicode_cmaps_set_their_characters_as_type() { check("/Ryumin-Light-UniJIS-UCS2-H findfont 20 scalefont setfont <00410042> stringwidth pop dup 5 gt exch 40 lt and"); } +/// The text objects of an import. +fn texts(r: &crate::Imported) -> Vec { + all(&r.document) + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some((**t).clone()), + _ => None, + }) + .collect() +} + +/// A CID-keyed font over one of the other predefined CMaps (Shift-JIS, EUC, GBK, Big Five, UHC, +/// JIS row and cell: ), the file defining +/// neither, its CIDFont only named: the codes are read by that CMap and its character +/// collection's CIDs to Unicode, and set as type in the font of the CIDFont's name, whether +/// found as `CIDFont-CMap` or composed. Codes the CMap doesn't map and control codes are left out. +#[test] +fn named_cidfonts_over_predefined_cmaps_set_their_characters_as_type() { + let cases: &[(&str, &str, &str)] = &[ + // 日 is 0x93FA and 本 0x967B in Shift-JIS. + ("83pv-RKSJ-H", "93fa967b41", "日本A"), + ("90ms-RKSJ-H", "93fa", "日"), + ("EUC-H", "c6fc", "日"), + // JIS row and cell: 日 is 0x467C. + ("H", "467c", "日"), + ("GBK-EUC-H", "d6d0", "中"), + ("B5pc-H", "a4a4", "中"), + ("ETen-B5-H", "a4a4", "中"), + ("KSCms-UHC-H", "c7d1", "한"), + // A CMap read with the nearest carried one: GB 2312 row and cell in EUC form, 中 0x5650. + ("GB-H", "5650", "中"), + // 0x8540 maps to no CID, 0xFD is in no code space range, 0x0D is a control code. + ("90ms-RKSJ-H", "854093fa41fd0d42", "日AB"), + ]; + for (cmap, hex, want) in cases { + let r = super::tests::read(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont 10 20 moveto <{hex}> show")); + assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{cmap}: {:?}", r.warnings); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), [*want], "{cmap}"); + let style = &t[0].runs[0].style; + assert_eq!((style.font_family.as_str(), style.font_style.as_str(), style.size), ("Ryumin", "Light", 20.0), "{cmap}"); + assert!(!t[0].vertical, "{cmap}"); + } + let r = super::tests::read("/X /GBK-EUC-H [/NotInFile] composefont 20 scalefont setfont 10 20 moveto show"); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::(), "中A"); + // The point moves past the type, along the line. + check("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont <93fa967b> stringwidth abs 0.01 lt exch dup 5 gt exch 60 lt and and"); +} + +/// Type in a CID-keyed font over a predefined CMap, its CIDFont only named, shown with `ashow`, +/// `widthshow` (matching a code by its value) and `xshow`, is spaced as the operator says: the +/// point moves that far, and the characters' tracking makes up the difference, as for type in a +/// font found by name. Over a vertical CMap the extra space runs down the column. +#[test] +fn named_cidfonts_over_predefined_cmaps_take_the_show_operators_spacing() { + // Measured without painting. + let measured = |body: &str| check(&format!("gsave nulldevice {body} grestore")); + let set = "/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont"; + // `ashow` adds 5 to each of the two codes. + measured(&format!( + "{set} 10 20 moveto <93fa967b> show currentpoint pop 10 20 moveto 5 0 <93fa967b> ashow currentpoint pop sub -10 sub abs 0.01 lt" + )); + let r = super::tests::read(&format!("{set} 10 20 moveto 5 0 <93fa967b> ashow")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["日本"]); + assert!(t[0].runs.iter().all(|run| (run.style.tracking - 250.0).abs() < 0.5), "{:?}", t[0].runs); + // `widthshow` adds 7 to the space, code 0x20. + measured(&format!( + "{set} 10 20 moveto <93fa20967b> show currentpoint pop 10 20 moveto 7 0 32 <93fa20967b> widthshow currentpoint pop sub -7 sub abs 0.01 lt" + )); + // A row-and-cell code read in its EUC form keeps the value of the string's bytes: 中 0x5650. + let gb = "/STSong-Light-GB-H findfont 20 scalefont setfont"; + measured(&format!( + "{gb} 10 20 moveto <56505650> show currentpoint pop 10 20 moveto 7 0 16#5650 <56505650> widthshow currentpoint pop sub -14 sub abs 0.01 lt" + )); + // `xshow` moves 30 a code. + measured(&format!("{set} 10 20 moveto <93fa967b> [30 30] xshow currentpoint pop 70 sub abs 0.01 lt")); + // Down the column: `ashow`'s 5 down each code. + let set = "/Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont"; + measured(&format!( + "{set} 10 200 moveto <93fa967b> show currentpoint exch pop 10 200 moveto 0 -5 <93fa967b> ashow currentpoint exch pop sub 10 sub abs 0.01 lt" + )); + let r = super::tests::read(&format!("{set} 10 200 moveto 0 -5 <93fa967b> ashow")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::(), "日本"); + assert!(t.iter().all(|t| t.vertical), "{t:?}"); +} + +/// A CID-keyed font over a vertical predefined CMap (`-V`), the file defining neither, its CIDFont +/// only named: the type is a vertical column, and the point moves down it, not along a line. +#[test] +fn named_cidfonts_over_vertical_cmaps_set_vertical_type() { + for (cmap, hex) in [("90ms-RKSJ-V", "93fa967b"), ("UniJIS-UCS2-V", "65e5672c")] { + let r = super::tests::read(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont 10 20 moveto <{hex}> show")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["日本"], "{cmap}: {:?}", r.warnings); + assert!(t[0].vertical, "{cmap}"); + check(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont <{hex}> stringwidth dup -60 gt exch -5 lt and exch abs 0.01 lt and")); + } +} + +/// Down a column each code of a CID-keyed font over a vertical predefined CMap moves the point one +/// em, the default vertical displacement (PDF Reference 1.7, §5.6.3, `DW2`), also two digits that +/// vertical type sets side by side in one em: `show` and `stringwidth` move the point as far as +/// showing the codes one at a time. +#[test] +fn codes_down_a_column_each_move_the_point_one_em() { + // Measured without painting. + let set = "gsave nulldevice /Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont"; + check(&format!("{set} (12) stringwidth grestore -40 sub abs 0.01 lt exch abs 0.01 lt and")); + check(&format!("{set} 10 100 moveto (12) show currentpoint grestore 60 sub abs 0.01 lt exch 10 sub abs 0.01 lt and")); + check(&format!( + "{set} 10 100 moveto (1) show (2) show currentpoint 10 100 moveto (12) show currentpoint grestore \ + 3 -1 roll sub abs 0.01 lt 3 1 roll sub abs 0.01 lt and" + )); +} + +/// Type in a CID-keyed font over a predefined CMap is set the same way by `show` and by the show +/// operators that space it: characters the font would join in a ligature (`fi` in the bundled +/// Source Serif 4) are set without the ligature, and the point moves as far. +#[test] +fn named_cid_type_is_set_alike_by_every_show_operator() { + let set = "/SourceSerif4-Regular-UniJIS-UCS2-H findfont 50 scalefont setfont 10 20 moveto"; + let features = |show: &str| { + let r = super::tests::read(&format!("{set} <00660069> {show}")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["fi"], "{show}"); + t[0].runs.iter().map(|r| r.style.features.clone()).collect::>() + }; + let plain = features("show"); + assert_eq!(plain, features("0 0 3 -1 roll ashow")); + assert!(plain.iter().all(|f| f.iter().any(|f| f == "-liga")), "{plain:?}"); + check(&format!( + "gsave nulldevice {set} <00660069> stringwidth 10 20 moveto 0 0 <00660069> ashow currentpoint grestore \ + 20 sub 3 -1 roll sub abs 0.01 lt 3 1 roll 10 sub sub abs 0.01 lt and" + )); +} + +/// A font name ending in the row-and-cell CMap `H` after a name without a hyphen stays a font of +/// the whole name, its strings read byte by byte. Type in a predefined CMap whose mappings aren't +/// available is left out with a warning, and a long string in a CID-keyed font over a predefined +/// CMap shown in a loop runs out of the budget. +#[test] +fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() { + let r = super::tests::read("/Futura-H findfont 20 scalefont setfont 10 20 moveto (AB) show"); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::>(), ["AB"]); + let r = super::tests::read("/Ryumin-Light-GBT-H findfont 20 scalefont setfont 10 20 moveto (AB) show 0 0 10 10 rectfill"); + assert!(texts(&r).is_empty(), "{:?}", all(&r.document)); + assert_eq!(super::tests::program_warnings(&r.warnings), [super::text::UNREAD_CMAP], "{:?}", r.warnings); + super::tests::runs_out_of_budget("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont { 100000 string stringwidth pop pop } loop"); +} + +/// A string in the file, in a CID-keyed font over a predefined CMap, too long to decode within the +/// memory budget stops the program with an error before it is decoded. +#[test] +fn huge_strings_over_predefined_cmaps_count_against_the_memory_budget() { + // Codes the CMap doesn't map, so nothing is laid out when the string is decoded. + let huge = "\u{1}".repeat(17 << 20); + let r = import(&super::tests::eps(&format!( + "0 0 10 10 rectfill /Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont 10 20 moveto ({huge}) stringwidth" + ))) + .unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings); +} + /// A Type 2 CIDFont the file carries (its glyph space one unit an em), over a Unicode CMap the /// file doesn't define, sets its characters as type at the font's size, not a thousand times it. #[test] diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 5868aa9228..ee9b0fc759 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -15,6 +15,7 @@ use super::cid::Base; use super::graphics::MAX_APART; use super::interp::{Interp, matrix_obj, matrix_of}; use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err}; +use super::predefined_cmap::Decoded; use super::type1::Embedded; /// The font a program uses before it sets one. @@ -170,8 +171,8 @@ impl Interp<'_> { ResourceForAll => self.resource_for_all()?, Show | AShow | WidthShow | AWidthShow | XShow | YShow | XYShow | KShow => { let (s, spacing) = self.show_operands(op)?; - if let Some((text, font)) = self.unicode_type(&s)? { - self.show_unicode(&text, &font)?; + if let Some((codes, font, vertical)) = self.named_cid_type(&s)? { + self.show_named_cid(&codes, &font, vertical, &spacing)?; } else if let Some(glyphs) = self.composite_glyphs(&s)? { self.show_composite(&glyphs, &spacing)?; } else { @@ -282,9 +283,9 @@ impl Interp<'_> { /// How far `s` moves the current point (user space). fn width_of(&mut self, s: &[u8]) -> Res { - if let Some((text, font)) = self.unicode_type(s)? { + if let Some((codes, font, vertical)) = self.named_cid_type(s)? { let saved = self.g.font.replace(font); - let r = self.set_text(&text, Point::ZERO).map(|t| t.2); + let r = self.named_cid_width(&codes, vertical); self.g.font = saved; return r; } @@ -395,18 +396,29 @@ impl Interp<'_> { } /// The font named `k`: one the program defined; else, for a name `CIDFont-CMap` whose CMap - /// is a predefined Unicode CMap, the CID-keyed font of those two (the usual name of a - /// CID-keyed font, as `composefont` makes them: PLRM 3rd ed., chapter 8); else a font of - /// that name. + /// is a predefined CMap, the CID-keyed font of those two (the usual name of a CID-keyed font, + /// as `composefont` makes them: PLRM 3rd ed., chapter 8; the CMaps are Adobe's public CMap + /// resources, ); else a font of that name. + /// A name an installed face has is that face's, and a name split at a bare `H` or `V` (the + /// row-and-cell CMaps) needs a CIDFont name with a hyphen in it (`Ryumin-Light-H`), so + /// `Futura-H` stays a font of that name. fn find_font(&mut self, k: &Obj) -> Res { let key = k.key().ok_or(PsError::Ps("typecheck", "findfont".into()))?; if let Some(Obj::Dict(d)) = self.fonts.borrow().get(&key) { return Ok(d.clone()); } let text = k.text().map(|t| t.to_string()).unwrap_or_default(); - let split = - text.match_indices('-').map(|(i, _)| i).find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some()); - if let Some((cid_font, cmap)) = split.and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))) { + let split = text + .match_indices('-') + .map(|(i, _)| i) + .find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some()) + .and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))) + .or_else(|| { + let (cid_font, cmap) = super::predefined_cmap::composite_font(&text)?; + let installed = vectorcraft_text::FontDb::global().find_postscript(&text).is_some(); + (!installed && !(matches!(cmap, "H" | "V") && !cid_font.contains('-'))).then_some((cid_font, cmap)) + }); + if let Some((cid_font, cmap)) = split { let cmap = self.find_cmap(&Obj::name(cmap))?; let dep = self.find_cid_font(&Obj::name(cid_font))?; return Ok(self.composed(key, cmap, vec![Obj::Dict(dep)])); @@ -476,6 +488,15 @@ impl Interp<'_> { /// [`Self::set_type`] for the characters `text`. fn set_text(&mut self, text: &str, at: Point) -> Res<(TextObject, TextLayout, Vec2)> { + self.set_text_in(text, at, false) + } + + /// [`Self::set_text`], as vertical type when `vertical`: a column anchored at `at`, the top + /// center of its first em box, that moves the current point down the column by the layout's + /// advances. In vertical writing the current point is a glyph's origin 1, by default half its + /// width across and 880 units of 1000 above its origin 0 (PDF Reference 1.7, §5.6.3 "Glyph + /// Metrics in CIDFonts", `DW2`). + fn set_text_in(&mut self, text: &str, at: Point, vertical: bool) -> Res<(TextObject, TextLayout, Vec2)> { // Laying out type costs about as much as 300 operations, and 64 more a character // (measured), so `{ (a) stringwidth pop pop } loop` or `cshow` on a long string ends // within the budget like any other loop instead of running for minutes. @@ -493,11 +514,29 @@ impl Interp<'_> { let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() }; let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() }; let mut t = TextObject::point(Point::ZERO, text, style); + t.vertical = vertical; let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); t.xf = m * Affine::scale_non_uniform(1.0 / size, -1.0 / size); t.cached_bounds = Some(layout.bounds); let adv: f64 = layout.glyphs.iter().map(|g| g.advance).sum(); - Ok((t, layout, f1 * Point::new(adv / size, 0.0) - f1 * Point::ZERO)) + let moved = if vertical { Point::new(0.0, -adv / size) } else { Point::new(adv / size, 0.0) }; + Ok((t, layout, f1 * moved - f1 * Point::ZERO)) + } + + /// [`Self::set_text_in`] for characters the file places one by one: where the layout joins + /// characters in a ligature, the type is set without ligatures, so each character has its own + /// advance. + fn set_placed_text(&mut self, text: &str, at: Point, vertical: bool) -> Res<(TextObject, TextLayout)> { + let (mut t, mut layout, _) = self.set_text_in(text, at, vertical)?; + let glyph_starts: std::collections::HashSet = layout.glyphs.iter().map(|g| g.byte).collect(); + if text.char_indices().any(|(b, _)| !glyph_starts.contains(&b)) { + for run in &mut t.runs { + run.style.features.push("-liga".into()); + } + layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + t.cached_bounds = Some(layout.bounds); + } + Ok((t, layout)) } /// `show`: point type at the current point, which moves past it. @@ -527,27 +566,30 @@ impl Interp<'_> { return self.show_type42_outlines(data, glyphs, s, spacing, at, f1); } // Each code's character, if it has one that shows (control codes don't). - let chars: Vec> = match &plain { + let chars = match &plain { Some((_, glyphs)) => glyphs.iter().map(|g| g.ch).collect::>(), None => self.named_chars(s).into_iter().map(Some).collect(), + }; + let mut codes = Decoded::default(); + for (c, &code) in chars.into_iter().zip(s) { + codes.push(u32::from(code), c.filter(|c| !c.is_control())); } - .into_iter() - .map(|c| c.filter(|c| !c.is_control())) - .collect(); - let text: String = chars.iter().flatten().collect(); - let (mut t, mut layout, _) = self.set_text(&text, at)?; + let widths: Option> = plain.map(|(_, glyphs)| glyphs.iter().map(|g| g.width).collect()); + self.show_codes(&codes, widths.as_deref(), spacing, false, s) + } + + /// The codes `codes` (each standing for some of its characters, or none) shown in the current + /// font as [`Self::show_plain`] shows a string's, spaced as `spacing` says, each code `widths` + /// wide when given (in glyph space, one unit an em), else as wide as its characters' advances; + /// as vertical type when `vertical`, the codes moving the point down the column. `kern` is the + /// string `kshow` gives its procedure the codes of. + fn show_codes(&mut self, codes: &Decoded, widths: Option<&[f64]>, spacing: &Spacing, vertical: bool, kern: &[u8]) -> Res { + let at = self.user_point()?; + let (_, f1) = self.font()?; + let text = &codes.text; + let (mut t, layout) = self.set_placed_text(text, at, vertical)?; let size = t.runs.first().map_or(1.0, |r| r.style.size); let starts: Vec = text.char_indices().map(|(b, _)| b).collect(); - // The file places each character's glyph: where the layout joins characters in a - // ligature, the type is set without ligatures, so each character has its own advance. - let glyph_starts: std::collections::HashSet = layout.glyphs.iter().map(|g| g.byte).collect(); - if starts.iter().any(|b| !glyph_starts.contains(b)) { - for run in &mut t.runs { - run.style.features.push("-liga".into()); - } - layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - t.cached_bounds = Some(layout.bounds); - } // Each character's advance in the layout (text space). let mut natural = vec![0.0; starts.len()]; for g in &layout.glyphs { @@ -555,29 +597,30 @@ impl Interp<'_> { *n += g.advance; } } - // Each code's move (user space) and that move in text space, along the baseline and across. - let em = |x: f64| f1 * Point::new(x, 0.0) - f1 * Point::ZERO; + // Each code's move (user space) and that move in text space, along the baseline (down the + // column) and across. + let em = |x: f64| f1 * if vertical { Point::new(0.0, -x) } else { Point::new(x, 0.0) } - f1 * Point::ZERO; let inv = f1.inverse(); - let mut moves = Vec::with_capacity(s.len()); + let mut moves = Vec::with_capacity(codes.codes.len()); let mut k = 0; // What `kshow`'s procedure draws goes after the type. let start = self.out.drawn.len(); let mut pen = at; - for (i, (&code, c)) in s.iter().zip(&chars).enumerate() { - let own = match &plain { - Some((_, glyphs)) => glyphs.get(i).map_or(0.0, |g| g.width), - None if c.is_some() => natural.get(k).copied().unwrap_or(0.0) / size, - None => 0.0, + for (i, &(code, n)) in codes.codes.iter().enumerate() { + let own = match widths { + Some(widths) => widths.get(i).copied().unwrap_or(0.0), + None => natural.get(k..k + n).map_or(0.0, |w| w.iter().sum::()) / size, }; - k += usize::from(c.is_some()); + k += n; let mut d = spacing.advance(i, i64::from(code), em(own)); - d += self.kern(spacing, s, i, pen + d)?; + d += self.kern(spacing, kern, i, pen + d)?; pen += d; let e = inv * Point::new(d.x, d.y) - inv * Point::ZERO; - if ![d.x, d.y, e.x * size, e.y * size].iter().all(|v| v.is_finite()) { + let (along, across) = if vertical { (-e.y * size, e.x * size) } else { (e.x * size, e.y * size) }; + if ![d.x, d.y, along, across].iter().all(|v| v.is_finite()) { return ps_err("undefinedresult", "a show"); } - moves.push((d, e.x * size, e.y * size)); + moves.push((d, along, across)); } let total: Vec2 = moves.iter().map(|m| m.0).sum(); if !(total.x.is_finite() && total.y.is_finite()) { @@ -589,12 +632,11 @@ impl Interp<'_> { if across { // Moves across the baseline (`yshow`, `xyshow`): each character where the file puts it. let mut pen = at; - for ((d, ..), c) in moves.iter().zip(&chars) { - if let Some(c) = c - && painted - && !c.is_whitespace() - { - let (t, ..) = self.set_text(c.encode_utf8(&mut [0; 4]), pen)?; + let mut chars = text.chars(); + for ((d, ..), &(_, n)) in moves.iter().zip(&codes.codes) { + let c: String = chars.by_ref().take(n).collect(); + if painted && !c.trim().is_empty() { + let (t, ..) = self.set_text_in(&c, pen, vertical)?; let clips = self.g.clips.clone(); self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); } @@ -605,11 +647,17 @@ impl Interp<'_> { // a character, before the next, are that character's. let mut lead = Vec2::ZERO; let mut target: Vec = Vec::with_capacity(natural.len()); - for ((d, along, _), c) in moves.iter().zip(&chars) { - match (c, target.last_mut()) { - (Some(_), _) => target.push(*along), - (None, Some(last)) => *last += along, - (None, None) => lead += *d, + for ((d, along, _), &(_, n)) in moves.iter().zip(&codes.codes) { + if n > 0 { + // A code of several characters: those before its last keep their advances. + let before = natural.get(target.len()..target.len() + n - 1).unwrap_or_default().to_vec(); + let rest = *along - before.iter().sum::(); + target.extend(before); + target.push(rest); + } else if let Some(last) = target.last_mut() { + *last += along; + } else { + lead += *d; } } let tracking: Vec = target @@ -931,23 +979,47 @@ impl Interp<'_> { .collect()) } - /// The characters of `s` and the font to set them in when the current font is a CID-keyed - /// font over a predefined Unicode CMap the file doesn't define (`UniJIS-UCS2-H`), whose - /// mappings to CIDs aren't known: its codes say the characters, set as type in the font of - /// its CIDFont's name. - fn unicode_type(&mut self, s: &[u8]) -> Res> { - let Some((text, font, name)) = self.unicode_parts(s) else { return Ok(None) }; + /// The codes of `s` with the characters each stands for, the font to set them in and whether + /// they run down a column, when the current font is a CID-keyed font over a predefined CMap + /// the file doesn't define (`UniJIS-UCS2-H`, `90ms-RKSJ-H`), whose mappings to CIDs the file + /// doesn't give: a Unicode CMap's codes are the characters, another CMap's are read by the CMap + /// of that name and its character collection's CID → Unicode CMap (a code it doesn't map + /// stands for none), set as type in the font of its CIDFont's name, vertical over a vertical + /// CMap (`WMode` 1). Control codes stand for no character. Type in a CMap VectorCraft can't + /// read is left out, with a warning. + fn named_cid_type(&mut self, s: &[u8]) -> Res> { + let Some((cmap, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) }; + // Each code is looked up in the CMap's mappings. + self.spend(s.len() as u64)?; + // The decoded codes and characters are held while the string is shown: checked against + // the memory budget first, so a huge string in the file stops the program with an error. + let held = s.len().saturating_mul(DECODED_BYTES); + self.alloc(held)?; + self.unalloc(held); + let decoded = match self.predefined_codes(&cmap) { + Some(codes) => codes.codes(s), + None => { + self.out.warn(UNREAD_CMAP); + Decoded::default() + } + }; + let mut codes = Decoded::default(); + let mut chars = decoded.text.chars(); + for &(code, n) in &decoded.codes { + codes.push(code, chars.by_ref().take(n).filter(|c| !c.is_control())); + } // The composite font is copied under the CIDFont's name for every string: charged by // its size, so a huge font dictionary shown in a loop runs out of the budget. let entries = font.borrow().len(); self.spend(entries as u64)?; let mut named = font.borrow().clone(); named.insert(Key::name("FontName"), name); - Ok(Some((text, DictRef::new(named)))) + Ok(Some((codes, DictRef::new(named), vertical))) } - /// The text, the composite font and the CIDFont's name of [`Self::unicode_type`]. - fn unicode_parts(&self, s: &[u8]) -> Option<(String, DictRef, Obj)> { + /// The CMap's name, the composite font, the CIDFont's name and the writing mode of + /// [`Self::named_cid_type`]. + fn named_cid_parts(&self) -> Option<(String, DictRef, Obj, bool)> { let font = self.g.font.clone()?; let f = font.borrow(); if font_type(&f) != Some(0.0) || f.get(&Key::name("FMapType")).and_then(Obj::as_num) != Some(9.0) { @@ -959,29 +1031,63 @@ impl Interp<'_> { if cmap.contains_key(&Key::name("CodeSpaceRange")) { return None; } - let (form, _) = super::cid::unicode_cmap(&cmap.get(&Key::name("CMapName")).and_then(Obj::text)?)?; + let cmap_name = cmap.get(&Key::name("CMapName")).and_then(Obj::text)?.to_string(); + if super::cid::unicode_cmap(&cmap_name).is_none() && !super::predefined_cmap::known(&cmap_name) { + return None; + } + let wmode = f.get(&Key::name("WMode")).or_else(|| cmap.get(&Key::name("WMode"))).and_then(Obj::as_num); let Some(Obj::Dict(dep)) = f.get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0)) else { return None }; let name = { let d = dep.borrow(); d.get(&Key::name("CIDFontName")).or_else(|| d.get(&Key::name("FontName"))).cloned()? }; - Some((super::cid::unicode_text(s, form), font.clone(), name)) + Some((cmap_name, font.clone(), name, wmode == Some(1.0))) } - /// `text` set as point type at the current point in font `font` (see - /// [`Self::unicode_type`]), which moves past it. - fn show_unicode(&mut self, text: &str, font: &DictRef) -> Res { - let at = self.user_point()?; - let saved = self.g.font.replace(font.clone()); - let r = self.set_text(text, at); - self.g.font = saved; - let (t, _, adv) = r?; - if !self.g.paint.is_none() && !self.g.null && !text.trim().is_empty() { - let clips = self.g.clips.clone(); - self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); + /// How strings in predefined CMap `cmap` decode (a CMap other than a Unicode CMap read once a + /// file); `None` when VectorCraft can't read it. + fn predefined_codes(&mut self, cmap: &str) -> Option> { + if let Some((form, _)) = super::cid::unicode_cmap(cmap) { + return Some(Rc::new(super::predefined_cmap::CMapCodes::Unicode(form))); } - self.g.cur = Some(self.xf() * (at + adv)); - Ok(()) + if let Some((_, codes)) = self.predefined_cmaps.iter().find(|(n, _)| **n == *cmap) { + return codes.clone(); + } + let codes = super::predefined_cmap::codes(cmap).map(Rc::new); + // Only the names of the other predefined CMaps reach here (`named_cid_parts`), so the + // list stays short. + self.predefined_cmaps.push((Rc::from(cmap), codes.clone())); + codes + } + + /// The codes `codes` (with their characters) set as point type at the current point in font + /// `font` (see [`Self::named_cid_type`]), running down a column when `vertical`, which moves + /// past them, spaced as `spacing` says, as [`Self::show_codes`] shows them. `widthshow` and + /// `awidthshow` match a code by its value (font number 0 times 256 plus the code: PLRM 3rd ed., + /// `widthshow` in chapter 8); `kshow`'s procedure isn't run between them (a composite font's + /// codes aren't single bytes). Down a column each code moves the point one em, the default + /// vertical displacement of a CIDFont whose metrics the file doesn't give (PDF Reference 1.7, + /// §5.6.3, `DW2`). + fn show_named_cid(&mut self, codes: &Decoded, font: &DictRef, vertical: bool, spacing: &Spacing) -> Res { + let widths = vertical.then(|| vec![1.0; codes.codes.len()]); + let saved = self.g.font.replace(font.clone()); + let r = self.show_codes(codes, widths.as_deref(), spacing, vertical, &[]); + self.g.font = saved; + r + } + + /// How far the codes `codes` move the current point (user space) when [`Self::show_named_cid`] + /// shows them with no extra spacing. + fn named_cid_width(&mut self, codes: &Decoded, vertical: bool) -> Res { + let (_, f1) = self.font()?; + let moved = if vertical { + Point::new(0.0, -(codes.codes.len() as f64)) + } else { + let (t, layout) = self.set_placed_text(&codes.text, Point::ZERO, false)?; + let size = t.runs.first().map_or(1.0, |r| r.style.size); + Point::new(layout.glyphs.iter().map(|g| g.advance).sum::() / size, 0.0) + }; + Ok(f1 * moved - f1 * Point::ZERO) } /// The characters of a composite font shown at the current point, spaced as `spacing` says. @@ -1282,6 +1388,13 @@ const MAX_TYPE1: usize = 256; /// The warning for CID-keyed type in a CIDFont the file doesn't carry. const MISSING_CID_FONT: &str = "CID-keyed type in a font the file doesn't carry was left out"; +/// Most bytes [`Interp::named_cid_type`] holds for each byte of a string: two lists of codes (16 +/// bytes a code) and their characters (up to 4 bytes a character, a few characters a code). +const DECODED_BYTES: usize = 64; + +/// The warning for type in a predefined CMap whose mappings VectorCraft doesn't have. +pub(super) const UNREAD_CMAP: &str = "Type in a CMap that couldn't be read was left out"; + /// Most Type 42 fonts whose TrueType data is kept between glyphs. const MAX_TYPE42: usize = 16; From a1cbb852b38861278869df67c24992bd7550eb0a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 02:05:08 -0800 Subject: [PATCH 39/79] Objects on Path: a Select/Move drag takes the place nearest the pointer on a path that turns back The drag chose its place from the point of the path nearest the pointer, but counted each point as farther by half its distance along the path from the last one. On an open path that turns back on itself, the object then stayed at its place while the pointer was over another object's ring 10 pt away. The drag now uses the point nearest the pointer. Where the path passes as near the pointer more than once, as where it runs back over itself, the drag follows the part nearest along the path to where it was. --- crates/tools/src/objects_on_path.rs | 53 +++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index a60c9fb15a..8f19259126 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -654,7 +654,9 @@ pub fn member_hover(cx: &ToolContext, p: Point) -> Option { /// pointer, and the objects between it and its place move one place toward where it was, except /// hidden and locked ones (`object.objectsOnPath.moveMember`). On an open path a pointer beyond /// an end takes the place at that end; on a closed path the pointer goes on round across the first -/// anchor. Where two places are as near, the one the drag is at stays. A click without a drag, and +/// anchor. Where the path passes as near the pointer more than once, as where it runs back over +/// itself, the drag follows the part nearest along the path to where it was. Where two places are +/// as near, the one the drag is at stays. A click without a drag, and /// the release of a drag, choose the object (`object.objectsOnPath.selectMember`). #[derive(Clone, Debug)] pub struct MemberDrag { @@ -671,7 +673,7 @@ pub struct MemberDrag { closed: bool, start: Point, began: bool, - /// The distance along the path to the point nearest the pointer at the last drag (points). + /// The distance along the path the drag was last at (points). last: f64, /// The place the drag puts the object at. target: usize, @@ -732,7 +734,14 @@ impl MemberDrag { out.push(Action::Begin("Move Object on Path".into())); } let w = &self.widget; - let Some((_, t)) = group_spine(cx.doc, w.group).and_then(|s| s.nearest(p, Some(self.last))) else { return out }; + let Some(spine) = group_spine(cx.doc, w.group) else { return out }; + let Some((near, t)) = spine.nearest(p, None) else { return out }; + // Where the path passes as near the pointer more than once, the point nearest along the path + // to where the drag was counts. + let t = match spine.nearest(p, Some(self.last)) { + Some((d, s)) if d - spine.apart(s, self.last) / 2.0 - near <= 1e-9 * (1.0 + near) => s, + _ => t, + }; if !t.is_finite() { return out; } @@ -1872,6 +1881,44 @@ mod tests { assert_eq!(previewed_target(&a), Some(ids[0].0), "{a:?}"); } + /// On an open path that turns back on itself, a drag takes the place nearest the pointer even + /// when that place is far along the path from where the drag is. + #[test] + fn a_select_move_drag_takes_the_place_under_the_pointer_on_a_path_that_turns_back() { + // The path runs 210 pt from (0, 0) right, down and back left to (0, 10): the four objects + // sit at 0, 70, 140 and 210 pt along it, the last one 10 pt below the first. + let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 10.0), Point::new(0.0, 10.0)]; + let path = vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&corners, false)]); + let (d, _, sel, ids) = members_doc(path, 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 0.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 0.0, 9.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 9.0)), vec![Action::Commit, choose(ids[0])]); + } + + /// Where an open path runs back over itself, a drag along the part that runs back stays on that + /// part. + #[test] + fn a_select_move_drag_follows_the_part_it_is_on_where_a_path_runs_back_over_itself() { + // The path runs 540 pt from (0, 0) right to (300, 0) and back left to (60, 0): the four + // objects sit at 0, 180, 360 and 540 pt along it, at x 0, 180, 240 and 60. + let corners = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(60.0, 0.0)]; + let path = vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&corners, false)]); + let (d, _, sel, ids) = members_doc(path, 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 240.0, 0.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 220.0, 0.0)); + assert!(previewed_target(&a).is_none(), "nearer its own place: {a:?}"); + let a = t.pointer(&c, &ev(PointerKind::Drag, 80.0, 0.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 80.0, 0.0)), vec![Action::Commit, choose(ids[2])]); + } + /// A hidden or locked object has no widget, and a drag does not take its place: the first /// object, dragged onto the locked last one's place, stays at the nearest place it can take. #[test] From 438a39fdf4577eb2af46675532851442fe8036e5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:09:18 -0800 Subject: [PATCH 40/79] Render: blend steps' drop shadows draw under an opacity mask with Clip off An object with an opacity mask draws offscreen over the bounds the renderer gives it. For a live object (a blend, envelope or repeat) those bounds took in effects in symbol art and brush art inside it, but not a path's own effects, so with Clip off the drop shadows of a blend's steps were cut off at the steps' bounds and did not draw where the mask art leaves them uncovered. The bounds now also take in how far each path's own effects paint past it. --- crates/render/src/fx/bounds.rs | 20 ++++++++++++++--- crates/render/src/tests.rs | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 9d5e9c0d1a..4b5055e67f 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -505,17 +505,31 @@ fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option { /// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual /// bounds: for each object in it, how far the effects in a symbol instance's art paint past that -/// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the -/// farthest side. 0 for any other kind of object. +/// art ([`art_effects_reach`]), how far brush art can paint ([`crate::brush_fx::pad`]) and how far +/// a path's own effects paint past it ([`path_effects_reach`]), on the farthest side. 0 for any +/// other kind of object. fn live_content_reach(symbols: &SymbolBoxes, n: &Node) -> f64 { if !vectorcraft_doc::live::is_live(n) { return 0.0; } let mut reach = 0.0f64; - n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c) + crate::brush_fx::pad(symbols, c))); + n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c).max(path_effects_reach(symbols, c)) + crate::brush_fx::pad(symbols, c))); reach } +/// How far the effects of path or compound path `n` (a drop shadow, a glow, a geometry effect) +/// paint past its visual bounds, on its farthest side ([`visual_bounds`]): 0 when it has no +/// visible effects, and for any other kind of object. +fn path_effects_reach(symbols: &SymbolBoxes, n: &Node) -> f64 { + if !has_fx(n) { + return 0.0; + } + match (visual_bounds(symbols, n), n.visual_bounds_with(symbols)) { + (Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max), + _ => 0.0, + } +} + /// How far the effects in the art of symbol instance `n` (and in the art of the symbols the /// instances in it show) paint past that art's visual bounds, on its farthest side, measured on the /// art as the instance places it before its effects run: 0 when the art has no visible effects, and diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 83f6131071..9cf4ac9ca0 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1260,6 +1260,45 @@ fn undecodable_image_in_nested_opacity_mask_leaves_out_outer_object() { assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "the incomplete inner mask invalidates the outer mask"); } +/// The drop shadows of a blend's steps draw under an opacity mask with Clip off, where the mask +/// art leaves them uncovered, as they draw without the mask; with Clip on they are hidden. +#[test] +fn blend_step_shadows_draw_under_an_unclipped_opacity_mask() { + use vectorcraft_doc::live::{BlendSpacing, BlendSpec}; + use vectorcraft_doc::{NodeKind, OpacityMask}; + + let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 15, "y": 15, "blur": 0}), visible: true }; + let blend = |d: &mut Document, mask: Option| { + let end = |d: &mut Document, x: f64| { + let mut n = rect_node(d, Rect::new(x, 10.0, x + 10.0, 20.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + n.appearance.effects.push(shadow.clone()); + Arc::new(n) + }; + let ends = vec![end(d, 10.0), end(d, 70.0)]; + let spec = BlendSpec { spacing: BlendSpacing::Steps(2), ..Default::default() }; + let mut n = Node::new(d.alloc_id(), NodeKind::Blend { children: ends, spec: Box::new(spec) }); + if let Some(clip) = mask { + let white = rect_node(d, Rect::new(0.0, 0.0, 5.0, 5.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(white, clip))); + } + n + }; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let draw = |mask: Option| { + let mut d = Document::new(120.0, 60.0); + let n = blend(&mut d, mask); + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + Renderer::new().render(&d, 120, 60, Affine::IDENTITY, &opts) + }; + let (plain, unclipped, clipped) = (draw(None), draw(Some(false)), draw(Some(true))); + for (x, y) in [(30, 30), (90, 32)] { + assert!(plain.pixel(x, y)[0] < 128, "the shadow draws at ({x}, {y}) without a mask: {:?}", plain.pixel(x, y)); + assert_eq!(unclipped.pixel(x, y), plain.pixel(x, y), "the shadow draws at ({x}, {y}) under the unclipped mask"); + assert_eq!(clipped.pixel(x, y), [255, 255, 255, 255], "the clipped mask hides the shadow at ({x}, {y})"); + } + assert_eq!(unclipped.pixel(15, 15), plain.pixel(15, 15), "the first end draws under the unclipped mask"); +} + /// #3580: each opacity mask is rendered over its object's pixels, not over the whole frame. 300 /// masked 4 × 4 pt squares on a 1000 × 1000 page draw offscreen in proportion to their bounds, and /// still draw where their masks show them. From 366d09d28546d8250ec6dcd60b7b39cb75cd4972 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 04:58:11 -0800 Subject: [PATCH 41/79] Save As: a save goes ahead when the portal answers with a file type of another shape The save dialog read the portal's current_filter with zvariant's tuple conversion, which panics on a structure with fewer fields than the tuple. On Linux and the BSDs the dialog's thread then ended after the user picked a file, and nothing was saved. The label is now read only from a (sa(us)) structure, the shape the FileChooser portal defines. An answer of another shape keeps the path and names no file type, as an answer without current_filter does. A structure with more fields than (sa(us)) also no longer names a file type. --- apps/vectorcraft/src/portal_save.rs | 52 ++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index 0357829aec..930938e03f 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -102,13 +102,22 @@ fn answer(code: u32, results: &HashMap) -> Result) = v.try_clone().ok()?.try_into().ok()?; - Some(label) - }); + let filter = results.get("current_filter").and_then(|v| filter_label(v)); Ok(Some(Saved { path, filter })) } +/// The label of a file type as the portal sends it, a `(sa(us))` structure; `None` for a value of +/// another shape. +fn filter_label(value: &Value<'_>) -> Option { + match value { + Value::Structure(s) => match s.fields() { + [Value::Str(label), Value::Array(globs)] if globs.element_signature() == "(us)" => Some(label.to_string()), + _ => None, + }, + _ => None, + } +} + /// `file:///home/me/A%20b.svg` → `/home/me/A b.svg` (its bytes); `None` for a file elsewhere /// (`file://host/`) or another scheme. fn file_uri_path(uri: &str) -> Option> { @@ -396,6 +405,36 @@ mod tests { assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster"]); } + /// A `current_filter` of another shape than `(sa(us))` (a structure with fewer, other or more + /// fields, a string) gives the path alone; `uris` of another shape is an error. + #[test] + fn an_answer_of_another_shape_is_not_read() { + let malformed = [ + Value::from(("SVG".to_string(),)), + Value::from(("SVG".to_string(), vec![(0u32,)])), + Value::from(("SVG".to_string(), vec![("*.svg".to_string(), 0u32)])), + Value::from((1u32, vec![(0u32, "*.svg".to_string())])), + Value::from(("SVG".to_string(), vec![(0u32, "*.svg".to_string())], 1u32)), + Value::from("SVG"), + ]; + for filter in malformed { + let mut r = results("file:///tmp/card", None); + r.insert("current_filter".to_string(), OwnedValue::try_from(filter).unwrap()); + assert_eq!(answer(0, &r), Ok(Some(Saved { path: "/tmp/card".into(), filter: None }))); + } + let malformed = [ + Value::from(vec![1u32]), + Value::from(vec![("file:///tmp/card".to_string(),)]), + Value::from(Vec::::new()), + Value::from("file:///tmp/card"), + ]; + for uris in malformed { + let mut r = HashMap::new(); + r.insert("uris".to_string(), OwnedValue::try_from(uris).unwrap()); + assert!(answer(0, &r).is_err()); + } + } + /// Cancel (1) and a dialog closed another way (2) answer no file; an answer without a local file /// is an error, and rfd shows the dialog instead. #[test] @@ -486,10 +525,7 @@ mod tests { } let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap)>() else { break }; let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok()); - let filter = options.get("current_filter").and_then(|v| { - let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?; - Some(label) - }); + let filter = options.get("current_filter").and_then(|v| filter_label(v)); let calls = log.lock().map(|mut l| { l.push((text("current_name"), filter)); l.len() From b9b1a1e967b0b99e35bcdd357017329b3e89c30e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:11:57 -0800 Subject: [PATCH 42/79] Effects: linked opacity masks follow group effects, Free Distort and envelopes A linked opacity mask moves with the art it masks, and an unlinked one stays in place. Geometry effects on a group or layer reshaped its members but left their linked masks where they were, Free Distort left a linked mask behind, and an envelope bent unlinked masks too. - Geometry effects on a group or layer run over the art of the linked masks of the paths, compound paths and groups inside it, nested and inverted masks included. Each Transform copy of a masked path carries its own mask, and an image or symbol instance moved by a Transform effect carries its linked mask. The effects on an object do not move that object's own mask. - When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, or the mask would get fewer Transform copies), the art and its masks keep their shape under that effect. Mask art counts toward the budget. - Free Distort (`object.distort`) warps a linked mask with its object; the params text says so. - An envelope bends only a linked mask. Docs: a line in docs/target-app-parity.md. --- crates/doc/src/live/expand.rs | 3 +- crates/effects/src/group/tests.rs | 263 ++++++++++++++++ crates/effects/src/reshape.rs | 329 +++++++++++++++++--- crates/engine/src/cmd/xform.rs | 8 +- crates/engine/src/tests/envelope_distort.rs | 26 ++ crates/engine/src/tests/xform.rs | 26 ++ docs/target-app-parity.md | 1 + 7 files changed, 614 insertions(+), 42 deletions(-) diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 8675dcc011..388bac227a 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -119,7 +119,8 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node { { out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps)); } - if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) { + // A linked opacity mask bends with the object it masks; an unlinked one stays where it is. + if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()).filter(|m| m.linked) { m.art = Arc::new(map_node(&m.art, w)); } match &mut out.kind { diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 16e893ce99..23f2f24c02 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -690,6 +690,269 @@ fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { } } +/// A white 10 pt square with id `id` at the origin. +fn white_square(id: u64) -> Node { + Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)) +} + +/// `n` masked by `art`, linked or not. +fn masked_by(mut n: Node, art: Node, linked: bool) -> Node { + let mut mask = vectorcraft_doc::OpacityMask::new(art, true); + mask.linked = linked; + n.extras_mut().mask = Some(Box::new(mask)); + n +} + +/// The geometry effects that reshape a group's members, one at a time. +fn reshaping_effects() -> Vec { + let fx = |id: &str, params: serde_json::Value| Effect { id: id.into(), params, visible: true }; + let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}); + vec![ + fx("distort.transform", serde_json::json!({"moveH": 30})), + fx("distort.transform", serde_json::json!({"rotate": 90})), + fx("distort.twist", serde_json::json!({"angle": 90})), + fx("distort.freeDistort", corners), + fx("warp.arc", serde_json::json!({})), + fx("distort.roughen", serde_json::json!({"size": 5, "seed": 3})), + fx("distort.zigZag", serde_json::json!({})), + fx("distort.puckerBloat", serde_json::json!({"amount": 50})), + fx("distort.tweak", serde_json::json!({})), + fx("path.offsetPath", serde_json::json!({"offset": 3})), + fx("stylize.roundCorners", serde_json::json!({"radius": 3})), + ] +} + +/// A group's geometry effect reshapes the art of a member's linked opacity mask as it reshapes the +/// member: a mask drawn over the member still covers it. An unlinked mask stays where it is. +#[test] +fn a_groups_geometry_effects_reshape_a_members_linked_mask_with_it() { + let square = white_square(1); + let original = square.path_data().cloned(); + for e in reshaping_effects() { + for linked in [true, false] { + let member = masked_by(square.clone(), white_square(4), linked); + let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + let mask = member.extras().mask.as_deref().unwrap(); + assert!(member.path_data().cloned() != original, "{}: the member is reshaped", e.id); + let want = if linked { member.path_data().cloned() } else { original.clone() }; + assert_eq!(mask.art.path_data().cloned(), want, "{} (linked: {linked})", e.id); + assert_eq!(mask.linked, linked); + } + } +} + +/// The mask of a group inside the group, the mask of a path in it, a mask inside that mask's art and +/// an inverted mask all take the outer group's effect with the paths they mask. +#[test] +fn a_groups_geometry_effects_reshape_nested_and_inverted_masks() { + for e in reshaping_effects() { + let innermost = masked_by(white_square(7), white_square(8), true); + let path = masked_by(white_square(1), masked_by(white_square(6), innermost, true), true); + let mut inner = masked_by(Node::group(NodeId(5), vec![Arc::new(path)]), white_square(4), true); + if let Some(m) = inner.extras_if_any().and_then(|x| x.mask.as_mut()) { + m.invert = true; + } + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let path = &inner.children().unwrap()[0]; + let want = path.path_data().cloned(); + let mask_of = |n: &Node| n.extras().mask.as_deref().map(|m| (*m.art).clone()).unwrap(); + let (outer_mask, path_mask) = (mask_of(inner), mask_of(path)); + assert!(inner.extras().mask.as_deref().is_some_and(|m| m.invert)); + assert_eq!(outer_mask.path_data().cloned(), want, "{}: the inner group's inverted mask", e.id); + assert_eq!(path_mask.path_data().cloned(), want, "{}: the path's mask", e.id); + assert_eq!(mask_of(&path_mask).path_data().cloned(), want, "{}: the mask inside the mask's art", e.id); + assert_eq!(mask_of(&mask_of(&path_mask)).path_data().cloned(), want, "{}: one level deeper", e.id); + } +} + +/// A Transform effect's copies of a path with a linked mask are objects of their own, each with the +/// mask moved with it; the original keeps its mask where it was. +#[test] +fn each_transform_copy_of_a_masked_member_carries_its_mask() { + let member = masked_by(white_square(1), white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(member)]); + let params = serde_json::json!({"moveH": 30, "copies": 2}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own"); + assert_eq!(copies.len(), 3); + for c in copies { + let mask = c.extras().mask.as_deref().unwrap(); + assert_eq!(mask.art.geometric_bounds(), c.geometric_bounds()); + } +} + +/// A Transform effect's copies of the paths of a member group give the group's linked mask the same +/// copies: the mask covers each copy. +#[test] +fn a_member_groups_mask_takes_the_transform_copies_of_its_paths() { + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 1}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let want = inner.geometric_bounds(); + assert_eq!(want, Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "the path and its copy"); + assert_eq!(inner.extras().mask.as_deref().unwrap().art.geometric_bounds(), want); +} + +/// A mask's art of several objects takes Transform copies whole, as the art it masks does: each copy +/// of the mask's art is a group of its objects, so the copies do not interleave object by object. +#[test] +fn a_masks_art_of_several_objects_is_copied_whole() { + let mask_art = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 1}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art; + let copies = mask.children().unwrap(); + assert_eq!(copies.len(), 2, "the mask's art and its copy"); + for c in copies { + let ids: Vec = c.children().into_iter().flatten().map(|m| m.id).collect(); + assert_eq!(ids, [NodeId(4), NodeId(5)], "each copy holds the mask's objects"); + } + assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 45.0, 10.0))); +} + +/// The linked mask of a mask's art that is copied whole takes the same Transform copies. +#[test] +fn the_mask_of_a_masks_art_copied_whole_takes_its_copies() { + let parts = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]); + let mask_art = masked_by(parts, white_square(6), true); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 1}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 45.0, 10.0)), "the mask's art and its copy"); + let own = &mask.extras().mask.as_deref().expect("the mask's art keeps its own mask").art; + assert_eq!(own.geometric_bounds(), Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "its mask covers the copy too"); +} + +/// When a member group's linked mask can't take all the Transform copies its paths take (past the +/// budget), the art keeps its shape under the effect, so the mask still covers the paths. +#[test] +fn art_whose_mask_would_take_fewer_copies_keeps_its_shape() { + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 1}), visible: true }; + let member = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true); + let mut art = Node::group(NodeId(10), vec![Arc::new(member)]); + let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines; + // Room for the path, its mask and a copy of the path, not for a copy of the mask. + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 3 * one }; + crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None); + let member = &art.children().unwrap()[0]; + let square = Some(Rect::new(0.0, 0.0, 10.0, 10.0)); + assert_eq!(member.geometric_bounds(), square, "the path keeps its shape"); + assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), square, "and so does its mask"); +} + +/// The copies of a path with a linked mask count the art of the mask each copy carries against the +/// budget: they stop where the masks would pass it. +#[test] +fn transform_copies_count_the_art_of_their_masks() { + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 2}), visible: true }; + let member = masked_by(white_square(1), white_square(4), true); + let mut art = Node::group(NodeId(10), vec![Arc::new(member)]); + let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines; + // Room for the path, its mask and one copy of both, not for a second copy. + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 4 * one }; + crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None); + let copies = art.children().unwrap()[0].children().expect("the object and its copy are objects of their own"); + assert_eq!(copies.len(), 2); + for c in copies { + assert_eq!(c.extras().mask.as_deref().unwrap().art.geometric_bounds(), c.geometric_bounds()); + } +} + +/// A Transform effect that scales strokes moves a group's paths as objects, their masks with them. +/// The mask of a member group, whose art holds a path with a mask of its own, moves with the group's +/// paths too. +#[test] +fn a_transform_that_moves_paths_as_objects_moves_a_member_groups_nested_mask() { + let mask_art = masked_by(white_square(4), white_square(5), true); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(3, 20.0, Color::BLACK)]); + let params = serde_json::json!({"moveH": 30, "strokes": true}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let want = inner.children().unwrap()[0].geometric_bounds(); + assert_eq!(want, Some(Rect::new(30.0, 0.0, 40.0, 10.0))); + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), want, "the group's mask"); + assert_eq!(mask.extras().mask.as_deref().unwrap().art.geometric_bounds(), want, "the mask inside its art"); +} + +/// A group's Transform effect moves an image member's linked opacity mask with the image, and its +/// copies' masks with the copies; an unlinked mask stays. +#[test] +fn a_groups_transform_moves_an_image_members_linked_mask() { + for linked in [true, false] { + for copies in [0, 2] { + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + let image = masked_by(Node::new(NodeId(1), NodeKind::Image(Box::new(im))), white_square(4), linked); + let mut g = Node::group(NodeId(10), vec![Arc::new(image)]); + let params = serde_json::json!({"moveH": 30, "copies": copies}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let first = &art.children().unwrap()[0]; + let images: Vec<&Node> = match first.children() { + Some(ch) => ch.iter().map(|c| &**c).collect(), + None => vec![first], + }; + assert_eq!(images.len(), copies + 1); + for im in images { + let mask = im.extras().mask.as_deref().unwrap(); + let want = if linked { im.geometric_bounds() } else { Some(Rect::new(0.0, 0.0, 10.0, 10.0)) }; + assert_eq!(mask.art.geometric_bounds(), want, "linked {linked}, copies {copies}"); + } + } + } +} + +/// When a path in a member's mask art would pass the effects' budget, the whole mask stays as it +/// was, rather than part of its art moving with the member, and the member keeps its shape with it. +#[test] +fn a_mask_whose_art_passes_the_budget_stays_whole() { + // Bloat bends a path's sides into curves drawn with more lines the larger they are: many more + // on the mask's large square. + let bloat = Effect { id: "distort.puckerBloat".into(), params: serde_json::json!({"amount": 50}), visible: true }; + let large = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 1000.0, 1000.0)), Appearance::default()); + let mask_art = Node::group(NodeId(3), vec![Arc::new(white_square(4)), Arc::new(large.clone())]); + let member = masked_by(white_square(1), mask_art.clone(), true); + let mut art = Node::group(NodeId(10), vec![Arc::new(member)]); + // Room for the member and the mask's small square to take the effect, not for the large one. + let bounds = art.geometric_bounds().unwrap(); + let ctx = crate::GeomContext { seed: 10, ..crate::GeomContext::of(&art) }; + let square = white_square(1).path_data().cloned().unwrap(); + let made = crate::Budget::of(&crate::apply_geometry_with(std::slice::from_ref(&bloat), &square, bounds, &ctx)).lines; + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 2 * made + crate::Budget::of(large.path_data().unwrap()).lines }; + let large_made = crate::Budget::of(&crate::apply_geometry_with(std::slice::from_ref(&bloat), large.path_data().unwrap(), bounds, &ctx)).lines; + assert!(large_made > crate::Budget::of(large.path_data().unwrap()).lines, "the large square's curves cost more lines"); + crate::reshape::reshape_leaves_within(&mut art, &[&bloat], budget, None); + let member = &art.children().unwrap()[0]; + assert_eq!(member.path_data(), Some(&square), "the member keeps its shape with its mask"); + assert_eq!(*member.extras().mask.as_deref().unwrap().art, mask_art, "the mask stays whole"); +} + /// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit /// pitch, each crossing every bar of the other direction: n² crossings. fn crossing_bars(n: usize, effect: &str) -> Node { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index c629cc10ca..14be7ee124 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -677,57 +677,114 @@ fn hoist_copies(art: &mut Node) { /// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused). /// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect /// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when -/// `owner` does not hold it. +/// `owner` does not hold it. The art of the linked opacity masks of the objects in `art` (not of +/// `art` itself) takes each effect as the paths it masks do ([`Step::reshape_masks`]). When a mask +/// stays as it was, or a Transform effect makes fewer copies than it asks for, art with linked +/// masks keeps its shape under that effect. fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry { let mut whole = crate::Carry::default(); if fx.is_empty() { return whole; } - let mut leaves_safe = true; - for_each_leaf(art, &mut |leaf| { - if !leaves_safe { - return; - } - if let Some((_, rule)) = leaf_geometry(leaf) { - let has_stroke = leaf.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); - if rule != FillRule::NonZero || has_stroke { - leaves_safe = false; - } - } - }); + let mut masks_safe = true; + each_linked_mask(art, false, 0, &mut |m| masks_safe &= leaves_safe(m)); let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); - let style_safe = leaves_safe && !art_stroke; + let style_safe = leaves_safe(art) && masks_safe && !art_stroke; let listed = fx; let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe); let fx = opt_fx.as_slice(); let seed = art.id.0; let mut used = art_cost(art); + each_linked_mask(art, false, 0, &mut |m| used = used.plus(art_cost(m))); let max = budget.at_least(used); // Read before the effects run: a path's own copies don't make it art of several objects. let copy_whole = copies_whole(art); + let masked = has_linked_mask(art, false); for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); // Where a live-effect plug-in sits, which it is listed by when it is deferred. let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e))); + let step = Step { e, params: ¶ms, bounds, seed, effect, apart, doc }; + // Art with linked masks keeps its shape under an effect that a mask in it could not take + // as the art it masks did, so no mask is left apart from its art. + let before = masked.then(|| (art.clone(), used)); + let ran = step.run(art, max, &mut used, copy_whole, false, 0); + if (ran.masks || ran.short) + && let Some((was, cost)) = before + { + *art = was; + used = cost; + continue; + } + if let Some(carry) = ran.whole { + whole.push(carry); + } + } + whole +} + +/// One geometry effect of an object run over its art ([`reshape_leaves_apart`]): the effect, its +/// merged params, the reference box and seed of the object's art, where a deferred live-effect +/// plug-in is listed, whether each Transform copy becomes an object, and the document that the +/// swatches and patterns of painted pieces are looked up in. +struct Step<'a> { + e: &'a Effect, + params: &'a serde_json::Value, + bounds: Rect, + seed: u64, + effect: Option, + apart: bool, + doc: Option<&'a Document>, +} + +/// What [`Step::run`] did to a piece of art. +struct Ran { + /// A path in the art kept its shape (past the budget). + kept: bool, + /// The linked opacity mask of an object in the art stayed as it was while the paths it masks + /// took the effect, or could have ([`Step::reshape_masks`]). + masks: bool, + /// A Transform effect made fewer copies of a path, image or symbol instance than it asks for + /// (past the budget or out of reach), where a linked mask could take a different number. + short: bool, + /// How what paints the art as a whole follows the effect, when it does. + whole: Option, +} + +impl Ran { + /// Nothing in the art kept its shape or made fewer copies, and every linked mask in it moved. + fn complete(&self) -> bool { + !self.kept && !self.masks && !self.short + } +} + +impl Step<'_> { + /// Run the effect over `art`, whose paths and those of the linked opacity masks in it keep to + /// `max` together with what they cost so far (`used`). `copy_whole` is [`copies_whole`] of the + /// art before its first effect, or of a mask's art before this effect. With `own`, `art` is the + /// art of a mask, and its own linked mask takes the effect too. `depth` counts the masks `art` is + /// in. + fn run(&self, art: &mut Node, max: Budget, used: &mut Budget, copy_whole: bool, own: bool, depth: usize) -> Ran { + let (e, params, bounds, seed) = (self.e, self.params, self.bounds, self.seed); if let Some(plugin) = painted_plugin(e) { - let at = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); + let at = self.effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); for_each_leaf(art, &mut |leaf| { let Some((path, _)) = leaf_geometry(leaf) else { return }; let before = Budget::of(&path); let room = max.minus(used.minus(before)); - if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc, at) { - used = used.minus(before).plus(made); + if let Some(made) = paint_leaf(leaf, plugin, params, bounds, room, self.doc, at) { + *used = used.minus(before).plus(made); } }); - continue; + return Ran { kept: false, masks: false, short: false, whole: None }; } // Placed gradients follow a Transform effect, Free Distort and the Warp effects // ([`crate::Carry`]): under Free Distort and Warp (`boxes`) each path's move and scale from // its bounds before the effect to its bounds after it. Under those and the other effects, // the placed gradients of type's outlines in `art` (`outline_text`) move and scale with // the outlines' bounds. - let follows = crate::Carry::follows(&e.id, ¶ms, bounds, seed); + let follows = crate::Carry::follows(&e.id, params, bounds, seed); let boxes = follows && crate::Carry::by_bounds(&e.id); let mut outlines = vec![]; if !follows || boxes { @@ -741,21 +798,44 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget if follows { group_marks(art, &mut vec![], &HashSet::new(), &mut groups); } - // The ids of the paths that kept their shape (past the budget). + // The ids of the paths that kept their shape (past the budget), and of those whose linked + // masks moved with them as they were transformed as objects. let mut kept_ids = HashSet::new(); + let mut moved_ids = HashSet::new(); let transform = (e.id == "distort.transform").then(|| { - let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); - let options = crate::distort::TransformOptions::of(¶ms); - (m, copies, options, crate::distort::transform_affine(bounds, ¶ms, seed)) + let (m, copies) = crate::distort::transform_map(bounds, params, seed); + let options = crate::distort::TransformOptions::of(params); + (m, copies, options, crate::distort::transform_affine(bounds, params, seed)) }); // Art of several objects is copied whole, so the copies don't interleave member by member. + // The copies of its members carry their linked masks, moved with them. if let Some((m, copies, options, _)) = transform && copies > 0 && copy_whole { - used = copy_art(art, m, copies, copy_scaling(options), max); - continue; + if !own { + *used = copy_art(art, m, copies, copy_scaling(options), max); + return Ran { kept: false, masks: false, short: false, whole: None }; + } + // A mask's art: its copies keep to what the rest of the art leaves of `max`, and the + // linked mask of the art itself takes them too. + let before = copy_cost(art); + let made = copy_art(art, m, copies, copy_scaling(options), max.minus(used.minus(before))); + *used = used.minus(before).plus(made); + let short = art.children().is_some_and(|c| c.iter().filter(|c| !c.is_guide()).count() <= copies); + let none = HashSet::new(); + let masks = self.reshape_own_mask(art, &Skip { moved: &none, kept: &none }, max, used, depth); + return Ran { kept: false, masks, short, whole: None }; } + // A shortfall in the copies matters for the objects a linked mask above them covers, which + // the mask would take a different number of copies than, and anywhere in a mask's art. + let watch = transform.is_some_and(|(_, copies, options, _)| copies > 0 && options.objects) && (own || has_linked_mask(art, false)); + let mut covered = HashSet::new(); + if watch && !own { + masked_ids(art, false, &mut covered); + } + let counts = |id: NodeId| watch && (own || covered.contains(&id)); + let mut short = false; for_each_leaf(art, &mut |leaf| { let Some((path, rule)) = leaf_geometry(leaf) else { return }; // What the other paths leave of the budget. @@ -766,15 +846,22 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget // strokes; a gradient paints each copy by itself, not the one piece the copies // make together; and Expand Appearance (`apart`) makes each copy an object. // Fills and strokes that stack paint each copy below the one before (#5920, #6316). + // Each copy of a path with a linked mask carries the mask, moved with it. let separate = copies > 0 - && (apart + && (self.apart || leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient() - || paints_copies_apart(&leaf.appearance, rule)); + || paints_copies_apart(&leaf.appearance, rule) + || leaf.extras().mask.as_ref().is_some_and(|m| m.linked)); + // The copies of a path replace it by a group of them, the path included. + let copied = |leaf: &Node| leaf.children().map_or(0, Vec::len); if options.reach_appearance() { let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); - used = used.minus(before).plus(made); - if !moved { + *used = used.minus(before).plus(made); + if moved { + moved_ids.insert(leaf.id); + short |= counts(leaf.id) && copied(leaf) <= copies; + } else { kept_ids.insert(leaf.id); } return; @@ -782,19 +869,27 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget if separate { let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() }; let made = copy_leaf(leaf, &path, m, copies, scaling, room); - used = used.minus(before).plus(made); + *used = used.minus(before).plus(made); + moved_ids.insert(leaf.id); + short |= counts(leaf.id) && copied(leaf) <= copies; return; } } - let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); - let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else { + let stage = self.effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); + let Some(out) = apply_one(&e.id, params, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else { kept_ids.insert(leaf.id); return; }; - used = used.minus(before).plus(Budget::of(&out)); + // A Transform effect's copies of a path are subpaths of it, each with the path's anchors. + if counts(leaf.id) + && let Some((_, copies, _, _)) = transform + { + short |= out.anchor_count() < path.anchor_count().saturating_mul(copies.saturating_add(1)); + } + *used = used.minus(before).plus(Budget::of(&out)); if follows && !boxes { // A gradient fitted to the path fits the path's own bounds. - let carry = crate::Carry::of(&e.id, ¶ms, bounds, path.bounds(), None, seed); + let carry = crate::Carry::of(&e.id, params, bounds, path.bounds(), None, seed); if carry.moves(&leaf.appearance) { carry.apply(&mut leaf.appearance); } @@ -807,24 +902,172 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget if let Some((m, copies, options, _)) = transform && options.objects { + let placed = if watch { masked_placed(art, own) } else { 0 }; transform_placed(art, m, copies); + short |= watch && masked_placed(art, own) < placed.saturating_mul(copies.saturating_add(1)); } if outlines.iter().any(Option::is_some) { rebase_type_outlines(art, &mut outlines.into_iter()); } + // Under Transform copies, a mask takes the copies as the paths it masks do; the copies of a + // path with a mask of its own each carry it ([`copy_leaf`]). + let masks = self.reshape_masks(art, own, &Skip { moved: &moved_ids, kept: &kept_ids }, max, used, depth); // What paints the art as a whole, and the own fills and strokes of the groups inside it, // follow their bounds under Free Distort and Warp (they stay when no path changed them). // Each stays when a path under it that its bounds measure kept its shape (past the budget). let mut after = vec![]; let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1; if !groups.is_empty() { - carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, ¶ms, bounds, from, to, seed)); + carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, params, bounds, from, to, seed)); } - if follows && !held { - whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), art.geometric_bounds(), seed)); + let whole = (follows && !held).then(|| crate::Carry::of(&e.id, params, bounds, Some(bounds), art.geometric_bounds(), seed)); + Ran { kept: !kept_ids.is_empty(), masks, short, whole } + } + + /// Run the effect over the art of the linked opacity mask of `n` (with `own`) and of each path, + /// compound path, group and layer under it, with the art's reference box, as the paths the mask + /// covers take it: the mask moves and bends with what it masks. The mask of an object + /// transformed as an object moved with it already and is left as it is. A mask stays as it was + /// past [`vectorcraft_doc::depth::MAX_DEPTH`] masks inside masks, and for the reasons + /// [`Step::reshape_own_mask`] gives. Returns whether a mask stayed for one of those reasons. + fn reshape_masks(&self, n: &mut Node, own: bool, skip: &Skip, max: Budget, used: &mut Budget, depth: usize) -> bool { + if depth >= vectorcraft_doc::depth::MAX_DEPTH || !has_linked_mask(n, own) { + return false; + } + let mut stayed = own && self.reshape_own_mask(n, skip, max, used, depth); + if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind { + for c in children.iter_mut() { + if has_linked_mask(c, true) { + stayed |= self.reshape_masks(Arc::make_mut(c), true, skip, max, used, depth); + } + } + } + stayed + } + + /// Run the effect over the art of the linked opacity mask of `n` itself, when `n` is a path, + /// compound path, group or layer that `skip` does not list as moved. The mask stays as it was + /// when a path it masks kept its shape, and when its own art could not take the effect whole: a + /// path in it would keep its shape, its copies would stop short, or a mask inside it would stay + /// (no mask is left half moved). Returns whether the mask stayed for one of those reasons. + fn reshape_own_mask(&self, n: &mut Node, skip: &Skip, max: Budget, used: &mut Budget, depth: usize) -> bool { + if depth >= vectorcraft_doc::depth::MAX_DEPTH || !reshapes_mask_of(n) || skip.moved.contains(&n.id) { + return false; + } + let stays = holds_any(n, skip.kept); + let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) else { return false }; + let before = *used; + let mut art = (*m.art).clone(); + let whole = copies_whole(&art); + let ran = (!stays).then(|| self.run(&mut art, max, used, whole, true, depth + 1)); + match ran { + Some(ran) if ran.complete() => { + // What paints the mask's art as a whole follows the effect as the art does. + if let Some(carry) = ran.whole + && carry.moves(&art.appearance) + { + carry.apply(&mut art.appearance); + } + m.art = Arc::new(art); + false + } + _ => { + *used = before; + true + } } } - whole +} + +/// The objects whose linked masks [`Step::reshape_masks`] leaves as they are: those transformed +/// as objects (`moved`), and those holding a path that kept its shape (`kept`). +struct Skip<'a> { + moved: &'a HashSet, + kept: &'a HashSet, +} + +/// Is `n` an object whose linked opacity mask a geometry effect on the art it is in reshapes with +/// it: a path, compound path, group or layer? An image or symbol instance moves its mask only +/// with the map a Transform effect places it by ([`transform_placed`]). +fn reshapes_mask_of(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Group { .. } | NodeKind::Layer { .. }) +} + +/// Does `n` (with `own`) or a path, compound path, group or layer under it carry a linked opacity +/// mask that [`Step::reshape_masks`] reshapes? +fn has_linked_mask(n: &Node, own: bool) -> bool { + let mine = own && reshapes_mask_of(n) && n.extras().mask.as_ref().is_some_and(|m| m.linked); + mine || match &n.kind { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().any(|c| has_linked_mask(c, true)), + _ => false, + } +} + +/// Run `f` on the art of the linked opacity mask of `n` (with `own`) and of each path, compound +/// path, group and layer under it, and on the masks inside that art, as [`Step::reshape_masks`] +/// reaches them: `depth` masks deep already, up to [`vectorcraft_doc::depth::MAX_DEPTH`]. +fn each_linked_mask(n: &Node, own: bool, depth: usize, f: &mut dyn FnMut(&Node)) { + if depth >= vectorcraft_doc::depth::MAX_DEPTH { + return; + } + if own + && reshapes_mask_of(n) + && let Some(m) = n.extras().mask.as_deref().filter(|m| m.linked) + { + f(&m.art); + each_linked_mask(&m.art, true, depth + 1, f); + } + if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &n.kind { + for c in children { + each_linked_mask(c, true, depth, f); + } + } +} + +/// Does [`Step::reshape_masks`] reshape the linked opacity mask of `n` itself? +fn has_own_linked_mask(n: &Node) -> bool { + reshapes_mask_of(n) && n.extras().mask.as_ref().is_some_and(|m| m.linked) +} + +/// Add to `out` the ids of the objects under `n` that the linked opacity mask of an object above +/// them covers (every one of them with `under`). +fn masked_ids(n: &Node, under: bool, out: &mut HashSet) { + for c in n.children().into_iter().flatten() { + if under { + out.insert(c.id); + } + masked_ids(c, under || has_own_linked_mask(c), out); + } +} + +/// How many images and symbol instances under `n` the linked opacity mask of an object above them +/// covers (every one of them with `under`). +fn masked_placed(n: &Node, under: bool) -> usize { + n.children().into_iter().flatten().map(|c| usize::from(under && is_placed(c)) + masked_placed(c, under || has_own_linked_mask(c))).sum() +} + +/// Is `n`, or anything under it, one of `ids`? +fn holds_any(n: &Node, ids: &HashSet) -> bool { + if ids.is_empty() { + return false; + } + let mut any = false; + n.walk(&mut |c| any |= ids.contains(&c.id)); + any +} + +/// Do the paths under `n` that [`for_each_leaf`] visits all fill by the non-zero rule without a +/// stroke that paints? Effects then reshape their outlines alike in any order that the +/// optimization of [`crate::geometry::optimize_geometry_effects`] takes. +fn leaves_safe(n: &Node) -> bool { + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => { + let has_stroke = n.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); + leaf_geometry(n).is_none_or(|(_, rule)| rule == FillRule::NonZero && !has_stroke) + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().all(|c| leaves_safe(c)), + _ => true, + } } /// Largest factor a Transform effect's Scale Strokes & Effects scales one object's strokes by @@ -1045,7 +1288,9 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling let mut copy = leaf.clone(); copy.transform(xf, scaling); // Each copy's own geometry effects make a different amount, as the copy and its effects scale. - let cost = Budget::of(&moved).plus(effects_cost(©, &moved)); + let mut cost = Budget::of(&moved).plus(effects_cost(©, &moved)); + // Each copy carries the art of its linked masks, moved with it. + each_linked_mask(©, true, 0, &mut |m| cost = cost.plus(art_cost(m))); if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) { break; } @@ -1623,6 +1868,12 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { if xf != Affine::IDENTITY && one.appearance.has_positioned_gradient() { one.appearance.transform_gradients(xf); } + // So does the art of its linked opacity mask. + if xf != Affine::IDENTITY + && let Some(m) = one.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) + { + Arc::make_mut(&mut m.art).transform(xf, false); + } one }; if copies == 0 { diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 9038598659..5f505f25c5 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Free Distort", [], None, - "{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles", + "{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays", has_doc, distort ), @@ -104,7 +104,8 @@ impl Projective { } /// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine -/// approximation at their centre. +/// approximation at their centre. The art of a linked opacity mask is warped with the object it +/// masks; an unlinked one stays where it is. fn warp_node(n: &mut Node, pr: &Projective) { n.pin_gradients(); match &mut n.kind { @@ -131,6 +132,9 @@ fn warp_node(n: &mut Node, pr: &Projective) { return; } } + if let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) { + warp_node(Arc::make_mut(&mut m.art), pr); + } n.appearance.warp_gradients(&|p| pr.affine_near(p)); } diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs index 8a1e46c68a..274e9c6548 100644 --- a/crates/engine/src/tests/envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -601,6 +601,32 @@ fn an_opacity_mask_on_type_bends_as_other_art() { assert_eq!(kept, 1, "the type's stroke stays a stroke"); } +/// An envelope bends the art of a linked opacity mask on its contents with them; an unlinked mask +/// stays where it is. +#[test] +fn an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one() { + for linked in [true, false] { + let mut s = session(); + let a = rect(&mut s); + let m = rect(&mut s); + sel(&mut s, &[a, m]); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + if !linked { + s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap(); + } + let before = node(&s, a).extras().mask.as_ref().unwrap().art.path_data().cloned(); + sel(&mut s, &[a]); + let e = arch(&mut s, 60.0, json!({})); + let mut found = vec![]; + for n in &evaluated(&s, e) { + n.walk(&mut |c| found.extend(c.extras().mask.as_ref().map(|k| (c.path_data().cloned(), k.art.path_data().cloned())))); + } + let [(bent, mask)] = found.as_slice() else { panic!("one masked object: {found:?}") }; + assert!(bent != &before, "the contents bend"); + assert_eq!(mask, if linked { bent } else { &before }, "linked: {linked}"); + } +} + /// Expand turns an envelope of stroked type into its bent glyph outlines with the stroke on them, /// at its own weight, as it draws (#6644). #[test] diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index e9aa3c5329..dcfda305d9 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -526,6 +526,32 @@ fn distort_command_maps_corners() { assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0))); } +/// Free Distort warps the art of a linked opacity mask with the object it masks, and one undo puts +/// both back; an unlinked mask stays where it is. +#[test] +fn distort_command_warps_a_linked_opacity_mask_with_its_object() { + for linked in [true, false] { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + if !linked { + s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap(); + } + let mask_path = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().extras().mask.as_ref().unwrap().art.path_data().cloned(); + let path = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().path_data().cloned(); + let before = (path(&s), mask_path(&s)); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.distort", &json!({"corners": [[10, 0], [90, 0], [100, 100], [0, 100]]})).unwrap(); + assert!(path(&s) != before.0); + let want = if linked { path(&s) } else { before.1.clone() }; + assert_eq!(mask_path(&s), want, "linked: {linked}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((path(&s), mask_path(&s)), before); + } +} + #[test] fn distort_perspective_midpoint_is_projective() { // Distort a tiny probe at the centre of a 100×100 source box into a trapezoid: with a real diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f28078ec33..52ef4e5f45 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -594,6 +594,7 @@ since 2026-10-01. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. +- **Linked opacity masks follow geometry effects, Free Distort and envelopes:** a linked opacity mask moves with the art it masks, and an unlinked one stays in place, as [Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html) describes for moving masked art. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, and each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too. The effects on an object do not move that object's own mask. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object in the same undo step. An envelope bends only a linked mask; before, it bent an unlinked one too. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, or the mask would get fewer Transform copies), the art and its masks keep their shape under that effect. Existing documents with a geometry effect on a group whose members have linked masks draw differently: before, those masks stayed where they were. Which masks follow the effects is VectorCraft's own rule (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`). Scores and hours unchanged. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. From 6849bd54222c485e12244985f2ef7f1ddb85d46c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:12:10 -0800 Subject: [PATCH 43/79] Docs: docs/plugins.md paragraphs wrap at the file's width Rewrap the prose in docs/plugins.md whose lines broke well short of the file's 120-column width or ran past it, including the Safe Mode sentence, the permission-name and install-confirmation sentences, and the grants.json paragraph. Each rewrapped line is at most 120 columns. No words, punctuation, code blocks or tables change. --- docs/plugins.md | 308 ++++++++++++++++++++++++------------------------ 1 file changed, 151 insertions(+), 157 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index d3af5fa06c..f616479167 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,9 +1,9 @@ # Plug-ins: the WebAssembly plug-in API (ABI v1) VectorCraft does not host native plug-ins built for other illustration apps: that needs unsafe FFI into -arbitrary machine code and cannot work in the browser build. Instead, plug-ins are **WebAssembly modules** -run in a sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's -plug-in API (same sandbox, same manifest and parameter schema), extended for vector documents. +arbitrary machine code and cannot work in the browser build. Instead, plug-ins are **WebAssembly modules** run in a +sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's plug-in API (same +sandbox, same manifest and parameter schema), extended for vector documents. There are four kinds of plug-in: @@ -20,8 +20,8 @@ There are four kinds of plug-in: paths and compound paths. File › Open lists their files, File › Open With lists the importers by name, and the host hands them the file's bytes. -This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the -`plugin.*` commands in `crates/engine/src/cmd/plugin.rs`). +This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the `plugin.*` commands +in `crates/engine/src/cmd/plugin.rs`). ## Using plug-ins @@ -44,14 +44,15 @@ An exporter adds a write-only format, `plugin.`, which `document.formats` li its extensions that no built-in format has; `selectedOnly` hands it the selection, as for the built-in formats. An importer adds a read-only format, `plugin.`, which `document.formats` lists with its `plugin` id. `openExtensions` -holds the extensions File › Open opens with the importer (the lists below describe which are left out). `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format -reads the file, by its content or by its extension, and the importer lists the file's extension; when several do, the -one whose id sorts first opens it. `document.open {"importer": "plugin.", "params"?: {…}}` names the importer, -which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg` -only this way). A damaged file of a built-in format reports that format's error, and the file is not handed to an -importer. The importer's objects become a new document whose artboard is their bounds (at least 1 pt wide and high, -and within ±4,000,000 pt); the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats, and a file only an importer reads opens as a new document when it is dropped on the canvas. These rules are -VectorCraft's own. +holds the extensions File › Open opens with the importer (the lists below describe which are left out). `document.open +{"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format reads the file, by its content or +by its extension, and the importer lists the file's extension; when several do, the one whose id sorts first opens it. +`document.open {"importer": "plugin.", "params"?: {…}}` names the importer, which must list the file's extension, +also one a built-in format reads (an importer that lists `svg` opens an `.svg` only this way). A damaged file of a +built-in format reports that format's error, and the file is not handed to an importer. The importer's objects become a +new document whose artboard is their bounds (at least 1 pt wide and high, and within ±4,000,000 pt); the document keeps +no path, so Save asks for a name. File › Place reads only the built-in formats, and a file only an importer reads opens +as a new document when it is dropped on the canvas. These rules are VectorCraft's own. In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload Plug-ins**; live effects under **Effect › Plug-ins** and in the Appearance panel's effect menu. A plug-in with @@ -60,25 +61,23 @@ of numbers and colors, labelled, explained and sorted into sections as its manif parameters it runs at once. Edit › Keyboard Shortcuts lists each installed filter under Object › Plug-ins, where it can be given a shortcut (`plugin:`); the shortcut stays saved while the plug-in is removed and applies again when it is reinstalled. Parameters too many for the window's height scroll inside the dialog, which keeps its -heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use -Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the -same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File › -Open's "All readable files" and under a filter of their own, named after the importer (other open dialogs, such as New -from Template and Load Styles, list only the files the built-in formats read). These lists leave out an importer's -extensions that a built-in format reads, and those File › Open installs, imports or loads as something else (`.wasm`, -`.kys`, `.jsx`, presets, libraries and swatch or graphic style libraries such as `.ase`). File › Open does not give -those files to the importer, and Open With does. **File › Open With** lists the -installed importers by name (`file.openWith {"importer", "path"?, "params"?}`), and each opens a file picked among its -extensions with that importer (on the web, File › Open's picker opens instead) and adds it to Open Recent. On the -desktop the importer runs off the UI thread, and a run that takes a while shows a progress window with Cancel, which -stops it. An importer -with parameters, given no `params`, asks for them in a dialog of the same kind once the file is picked, and its Open -button opens the file; File › Open runs an importer with its parameters' defaults. Open Recent opens a file the way -File › Open does: it does not remember the importer or the parameters given to Open With. A built-in format reads the -file if one can, and otherwise the first importer by id that lists its extension reads it with its parameters' defaults. -**File › Open** of a `.wasm` file installs -a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the -background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed +heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use Artboards is greyed out +(the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is +picked, and its Export button writes the file. Importers' files are listed under File › Open's "All readable files" and +under a filter of their own, named after the importer (other open dialogs, such as New from Template and Load Styles, +list only the files the built-in formats read). These lists leave out an importer's extensions that a built-in format +reads, and those File › Open installs, imports or loads as something else (`.wasm`, `.kys`, `.jsx`, presets, libraries +and swatch or graphic style libraries such as `.ase`). File › Open does not give those files to the importer, and Open +With does. **File › Open With** lists the installed importers by name (`file.openWith {"importer", "path"?, +"params"?}`), and each opens a file picked among its extensions with that importer (on the web, File › Open's picker +opens instead) and adds it to Open Recent. On the desktop the importer runs off the UI thread, and a run that takes a +while shows a progress window with Cancel, which stops it. An importer with parameters, given no `params`, asks for them +in a dialog of the same kind once the file is picked, and its Open button opens the file; File › Open runs an importer +with its parameters' defaults. Open Recent opens a file the way File › Open does: it does not remember the importer or +the parameters given to Open With. A built-in format reads the file if one can, and otherwise the first importer by id +that lists its extension reads it with its parameters' defaults. **File › Open** of a `.wasm` file installs a plug-in +too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the background +([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed ([below](#permissions)). ### Commands @@ -117,9 +116,9 @@ the object is drawn as if the effect weren't there, and the record is kept for w ## The sandbox -The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssembly interpreter -(MIT OR Apache-2.0). It builds for `wasm32-unknown-unknown` too, so plug-ins also run in the web build. Limits -(`vectorcraft_plugins::Limits`, defaults): +The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssembly interpreter (MIT OR Apache-2.0). It +builds for `wasm32-unknown-unknown` too, so plug-ins also run in the web build. Limits (`vectorcraft_plugins::Limits`, +defaults): | Limit | Default | On violation | |---|---|---| @@ -155,15 +154,15 @@ measured with the page's `performance.now()`. Some filters do heavy but legitimate work, such as routing a plotter's pen, packing shapes onto a sheet or running a simulation, which a minute and 50 M instructions don't cover. Such a filter declares `"budget": "heavy"` in its manifest (only a filter may: a live effect runs whenever the document is drawn, an exporter has an export's budget and -an importer an import's). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, and 300 s -(`vectorcraft_plugins::{HEAVY_FUEL, HEAVY_WALL_TIME_MS}`). Its output can't add to the budget: the host learns how -much there is only once the run has ended. +an importer an import's). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, +and 300 s (`vectorcraft_plugins::{HEAVY_FUEL, HEAVY_WALL_TIME_MS}`). Its output can't add to the budget: the host learns +how much there is only once the run has ended. - **Installing asks first.** Install Plug-in… and File › Open show what the filter asks for, and install it once the user clicks OK (see [Permissions](#permissions) for who can). `plugin.install` refuses it without `allowHeavy: true`, and refuses `allowHeavy` from a request except in a session started with `vectorcraft-cli --allow-plugin-grants` - (`plugin.inspect` shows the budget beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed again - at startup. The Additional Plug-ins Folder skips heavy filters with a note in its report, since loading a folder + (`plugin.inspect` shows the budget beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed + again at startup. The Additional Plug-ins Folder skips heavy filters with a note in its report, since loading a folder asks nobody. - **It runs in the background.** Its dialog has no Preview. OK (or the menu item, without parameters) runs it on a thread of its own while editing goes on, shown in the status bar and, after a moment, in a progress window with @@ -203,17 +202,15 @@ capability model proposed for PhotoCraft plug-ins `state`, which only its commands use. A filter may list any of its three, and `commands` with a `commandAllowlist`. Any kind may add `progress`, `settings` and `cache`, which no kind implies. An unknown name, a name listed twice, a name that doesn't apply to the kind, more than 16 names, `progress`, `settings`, `cache` or `commands` with an `api` - below 2, or - one of the names kept for a later plug-in API - (`ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. + below 2, or one of the names kept for a later plug-in API (`ui.panel`, `ui.tool`) is refused when the module is + installed, and the error names it. - **Installing asks first.** Install Plug-in… and File › Open show the plug-in's name, id, version, the SHA-256 of the file and each permission in plain words, and install it once the user clicks OK, if the file still has the - SHA-256 shown; Cancel installs nothing. The - names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. - Only a person can press its OK: `ui.dialog.confirm`, MCP's `answer_dialog` and injected input (`ui.key`, `ui.click`) - are refused and the dialog stays open. Enter confirms it only when OK has the keyboard focus. `ui.dialog.set` can't - change what it installs. OK stays disabled until the question has been on screen in the focused window for half a - second (VectorCraft's own value, from the delay the security guidance at + SHA-256 shown; Cancel installs nothing. The names shown have control and bidirectional characters escaped (`\u{202E}`) + and are cut to 64 characters. Only a person can press its OK: `ui.dialog.confirm`, MCP's `answer_dialog` and injected + input (`ui.key`, `ui.click`) are refused and the dialog stays open. Enter confirms it only when OK has the keyboard + focus. `ui.dialog.set` can't change what it installs. OK stays disabled until the question has been on screen in the + focused window for half a second (VectorCraft's own value, from the delay the security guidance at https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/security/security-considerations-for-browser-ui.md gives for dialog boxes), so a click, Enter or Space, or a press begun before then, installs nothing. The half second starts over for every new question, even one that reads the same, and when the window gets the focus back. Cancel @@ -280,23 +277,22 @@ capability model proposed for PhotoCraft plug-ins does not count for any session in the same process that uses the same `Plug-ins` folder, by any path to it, until `grants.json` can be written for that plug-in, as when the plug-in is granted again. Another process on the same folder, for example `vectorcraft-cli` beside the app, still reads `grants.json` as it is written. A grant for the - same id kept in another `Plug-ins` folder counts. In Safe Mode, and for - a stored module that the user disabled, + same id kept in another `Plug-ins` folder counts. In Safe Mode, and for a stored module that the user disabled, VectorCraft does not read the module, and `grants.json` keeps the grant for its id. The Additional Plug-ins Folder and `plugin.reload` do not change the `Plug-ins` folder: when they leave a module that lists no permissions under an id, the process forgets its own grant for that id, and a grant kept in `grants.json` stays with the stored module it covers. An entry of `grants.json` that this version can't use (a permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again, replaced by a module that lists no permissions, or removed. When such an entry can't be removed from the file, `plugin.install` and `plugin.remove` treat it as a grant - that can't be removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than - 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept - in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, - a stored plug-in is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins - Folder and `plugin.reload` install only plug-ins that such a grant covers, and report the others. A module without a + that can't be removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written + larger than 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept in it count for + `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, a stored plug-in + is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins Folder and + `plugin.reload` install only plug-ins that such a grant covers, and report the others. A module without a `permissions` list, or with an empty one, needs no grant. -- The host enforces the grant on a filter run and a command run: without `doc.read` the filter is given no objects; without `state` its - `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the - document is refused. A refused output leaves the document as it was. +- The host enforces the grant on a filter run and a command run: without `doc.read` the filter is given no objects; + without `state` its `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that + changes the document is refused. A refused output leaves the document as it was. - `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds the [host imports](#host-imports-api-level-2), a filter's `inputs` ([reading type, images and symbols](#reading-type-images-and-symbols)) and a live effect's [`paintOutput`](#painted-live-effects). A higher one @@ -307,12 +303,11 @@ capability model proposed for PhotoCraft plug-ins - Parameter declarations are unchanged by permissions (`params`, below). Every run gets a **fresh instance**, so no state survives between calls except what a plug-in writes to its -[settings and cache](#settings-and-cache). Traps, exhausted budgets, malformed -modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the -document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that -fails draws the object's geometry unchanged; `plugin.info` reports why (`lastError`). A run that succeeds clears the -error of a failed run. A check that finds every swatch and pattern a painted result names clears only the error of a -refused swatch or pattern. +[settings and cache](#settings-and-cache). Traps, exhausted budgets, malformed modules, bad manifests, out-of-range +pointers, negative return codes and invalid output all become errors, and the document is left untouched: a filter's +output is checked completely before one edit applies it. A live effect that fails draws the object's geometry unchanged; +`plugin.info` reports why (`lastError`). A run that succeeds clears the error of a failed run. A check that finds every +swatch and pattern a painted result names clears only the error of a refused swatch or pattern. wasmi is built with its *portable* (loop) dispatcher: the default tail-call dispatcher relies on LLVM sibling-call optimisation, and when that doesn't happen a long-running module overflows the host stack, which aborts @@ -365,12 +360,12 @@ still returns `1`. `vc_progress`, `vc_cancelled` and `vc_log` need `progress`; t filter that lists `progress` runs in the background as a heavy one does, under its light budget, when it runs at once from Object › Plug-ins or its shortcut, or when OK runs it with Preview off; with Preview on, its dialog keeps the previewed run. A filter that also lists a `commandAllowlist` runs where it is called, since its command calls need - the document. A plug-in that checks `vc_cancelled` can - stop early, and the host discards what it returns after the Cancel or after its time budget ran out; one that - ignores it is still stopped by the instruction and time budgets. With a known total, a report is passed on only when - it reaches another tenth of a percent; with an unknown total, each change of `done` is passed on. After 2,000 reports - in a run, one change in 1,000 is passed on, and so is the first report after the total becomes known or unknown. A - live effect's progress is not shown, since it is drawn as part of something else. + the document. A plug-in that checks `vc_cancelled` can stop early, and the host discards what it returns after the + Cancel or after its time budget ran out; one that ignores it is still stopped by the instruction and time budgets. + With a known total, a report is passed on only when it reaches another tenth of a percent; with an unknown total, each + change of `done` is passed on. After 2,000 reports in a run, one change in 1,000 is passed on, and so is the first + report after the total becomes known or unknown. A live effect's progress is not shown, since it is drawn as part of + something else. - Log lines go to the app's log (target `vectorcraft_plugins`) as `: `, with control and bidirectional characters escaped. The whole text must be UTF-8. A line shows its first 1,024 bytes, as at most 1,024 characters with the escapes, and ends with `…` when it is cut. A run writes at most 200 lines and 32 KiB of shown @@ -405,8 +400,7 @@ or modified time differs from the last read or write. A get answered from memory set to the value the key already has writes nothing, and neither does a delete of a key that has no value. A delete of the last setting removes the file and counts no written bytes. Every other successful set or delete writes the file before the call returns. A cache call reads or writes the whole entry in memory in one step, up to 64 MiB, and -the run waits for it. -A live effect runs while the art draws, so keep the entries it uses small. +the run waits for it. A live effect runs while the art draws, so keep the entries it uses small. - A key is 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, and is not a Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM0` to `COM9`, `LPT0` to `LPT9`, in any case). Keys differ in case: `tile` and `Tile` are two. @@ -416,28 +410,27 @@ A live effect runs while the art draws, so keep the entries it uses small. also fails with `-4` when the `cache` folder holds more than 8,192 items; clearing the cache removes every file in it. - The desktop app and `vectorcraft-cli` keep them in a folder named from the id under `storage/` in the `Plug-ins` - folder (ids that differ only in case get two folders): `settings.json` and a - `cache` folder with one file per entry, named by the host. A settings file that is too large or doesn't parse is - moved to `settings.unreadable.json`, replacing an earlier one there, and the settings start empty. A link in that - folder is refused (`-5`). Writes go to a temporary file that then replaces the old one; a temporary file a failed - write leaves in the `cache` folder does not count toward the caps, and clearing the cache removes it. + folder (ids that differ only in case get two folders): `settings.json` and a `cache` folder with one file per entry, + named by the host. A settings file that is too large or doesn't parse is moved to `settings.unreadable.json`, + replacing an earlier one there, and the settings start empty. A link in that folder is refused (`-5`). Writes go to a + temporary file that then replaces the old one; a temporary file a failed write leaves in the `cache` folder does not + count toward the caps, and clearing the cache removes it. - A plug-in reaches only the storage of its own id. Replacing it with another module of the same id - (`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a run of the plug-in still going gets `-5` from every storage call after that. - `plugin.remove` first renames the folder, adding `.removed` to its name (or `.removing` while a `.removed` folder - holds settings an earlier removal kept). When that fails, `plugin.remove` fails and the plug-in stays installed with - its storage as it was. Before VectorCraft deletes a renamed folder, it writes an empty file beside it with `.delete` - added to the folder's name, and removes that file once the folder is gone. When that file can't be written, the - folder is not deleted and is treated like any renamed folder without a `.delete` file (see below). Files that can't be deleted stay in the - renamed folder with the `.delete` file beside it, where no plug-in reaches them. A later `plugin.remove` tries again - to delete them when a plug-in with that id is installed or a storage folder for the id exists, and that deletion can - fail too. For an id that isn't installed and has only such a folder, `plugin.remove` does not delete it and fails as - not found. When `plugin.remove` can't remove the grant and can't rename the folder back either, its error names the - renamed folder. The next load, install or `plugin.remove` of the plug-in renames a renamed folder that has no - `.delete` file back while the plug-in has no folder of its own; when it has one, that folder is used and the renamed - folder stays until `plugin.remove` deletes both. - `plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept for that id, when there is - any. - `plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and + (`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a + run of the plug-in still going gets `-5` from every storage call after that. `plugin.remove` first renames the folder, + adding `.removed` to its name (or `.removing` while a `.removed` folder holds settings an earlier removal kept). When + that fails, `plugin.remove` fails and the plug-in stays installed with its storage as it was. Before VectorCraft + deletes a renamed folder, it writes an empty file beside it with `.delete` added to the folder's name, and removes + that file once the folder is gone. When that file can't be written, the folder is not deleted and is treated like any + renamed folder without a `.delete` file (see below). Files that can't be deleted stay in the renamed folder with the + `.delete` file beside it, where no plug-in reaches them. A later `plugin.remove` tries again to delete them when a + plug-in with that id is installed or a storage folder for the id exists, and that deletion can fail too. For an id + that isn't installed and has only such a folder, `plugin.remove` does not delete it and fails as not found. When + `plugin.remove` can't remove the grant and can't rename the folder back either, its error names the renamed folder. + The next load, install or `plugin.remove` of the plug-in renames a renamed folder that has no `.delete` file back + while the plug-in has no folder of its own; when it has one, that folder is used and the renamed folder stays until + `plugin.remove` deletes both. `plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept + for that id, when there is any. `plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and `plugin.info` give the cache's size as `cacheBytes`. - Without a `Plug-ins` folder (the web build, and embedders that attach none) the storage calls return `-5`. @@ -458,8 +451,9 @@ has the id, `-6` when a plug-in may not call the command, `-7` when the command `-8` when the command ran and failed, `-9` when a limit below is passed, and `-11` when nothing can be called (from the module's start function, in a run that isn't a filter's run on a document, or after a call in the run failed). -- Every code also fails the run, whatever the plug-in does next, and the error names the call and why it failed. The run's calls and its output are one undo step, named after the plug-in or its command. A run that fails, - traps, runs out of budget or is canceled leaves every document, its selection and its history as they were. +- Every code also fails the run, whatever the plug-in does next, and the error names the call and why it failed. The + run's calls and its output are one undo step, named after the plug-in or its command. A run that fails, traps, runs + out of budget or is canceled leaves every document, its selection and its history as they were. - Each call runs on the document the run is for, with its params checked as any request's are, and sees what the calls before it changed: a `select.all` followed by `object.move` moves everything. The filter's own input is the one it was given when the run started; an edit still in progress (typing, or a shape being drawn) is kept as its own undo @@ -540,18 +534,17 @@ the module's start function, in a run that isn't a filter's run on a document, o no empty part between dots. A malformed list is refused when the module is installed, and the error names the id. - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, and not `id`, `ids`, `params` or `preview`. The host validates the user's values before calling: missing values take the default, numbers are - clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults - are also clamped to their range, including the implicit zero default of an `int` parameter. Besides `number`, - `int`, `bool` and `choice` there are `string` (`{"type": "string", "default": "", "maxLength": 256}`: a text field; - longer text is cut to `maxLength`, at most 4,096 characters) and `numberList` (`{"type": "numberList", "min": 0, - "max": 360, "default": [45, 135], "minItems": 1, "maxItems": 16}`: numbers typed separated by commas or spaces, - each clamped to `min..max`; a list of too few or too many is an error, and `maxItems` is at most 256) and `color` - (`{"type": "color", "default": "#3a7bd5"}`: a color button that opens a color picker; the default, black when left - out, and a value given to `plugin.run` may be a color object as paints hold one or a hex string, and the plug-in - always gets a color object, its components clamped, such as `{"model": "rgb", "r": 0.23, "g": 0.48, "b": 0.84}`; a - hex string and the picker's choice are display colors, given as the working RGB color that shows as them, so in a - working RGB space other than sRGB the numbers differ from the hex). Hosts from before `color` refuse a manifest that - uses it. A plug-in declares up to 256 + clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults are also clamped + to their range, including the implicit zero default of an `int` parameter. Besides `number`, `int`, `bool` and + `choice` there are `string` (`{"type": "string", "default": "", "maxLength": 256}`: a text field; longer text is cut + to `maxLength`, at most 4,096 characters) and `numberList` (`{"type": "numberList", "min": 0, "max": 360, "default": + [45, 135], "minItems": 1, "maxItems": 16}`: numbers typed separated by commas or spaces, each clamped to `min..max`; a + list of too few or too many is an error, and `maxItems` is at most 256) and `color` (`{"type": "color", "default": + "#3a7bd5"}`: a color button that opens a color picker; the default, black when left out, and a value given to + `plugin.run` may be a color object as paints hold one or a hex string, and the plug-in always gets a color object, its + components clamped, such as `{"model": "rgb", "r": 0.23, "g": 0.48, "b": 0.84}`; a hex string and the picker's choice + are display colors, given as the working RGB color that shows as them, so in a working RGB space other than sRGB the + numbers differ from the hex). Hosts from before `color` refuse a manifest that uses it. A plug-in declares up to 256 parameters, at most 64 of them outside groups. Each parameter may also say how dialogs show it. Hosts from before these keys ignore them, so a plug-in that @@ -576,17 +569,18 @@ parameters uses `label`, `unit`, `help` and `visibleWhen`, and lists the paramet order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide (compound guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never touches unless its manifest lists them in `inputs` ([reading type, images and -symbols](#reading-type-images-and-symbols)). With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one -object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and `strokes`, as a filter gets them (hosts -from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a -fill's colour to decide its shape, but it changes only the geometry, unless it declares `paintOutput` ([painted live -effects](#painted-live-effects)). +symbols](#reading-type-images-and-symbols)). With nothing selected the list is empty (a generator then adds its own +objects). A live effect gets one object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and +`strokes`, as a filter gets them (hosts from before these give geometry only). The paints are read only: an effect may +draw as wide as a stroke or use a fill's colour to decide its shape, but it changes only the geometry, unless it +declares `paintOutput` ([painted live effects](#painted-live-effects)). An **exporter** gets something else: the native `.vectorcraft` document JSON without indentation, optional preview/PDF -renditions or save-preference metadata (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, -appearance and effect record, artboard, swatch and symbol, so an exporter reads whatever the document holds; it checks -`version`, since a later host may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME -type and `blob` but without their data, which follows the JSON in a saved file; smaller ones keep their base64 `data`. +renditions or save-preference metadata (the format is documented in +[`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, appearance and effect record, +artboard, swatch and symbol, so an exporter reads whatever the document holds; it checks `version`, since a later host +may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME type and `blob` but without their +data, which follows the JSON in a saved file; smaller ones keep their base64 `data`. An **importer** gets the bytes of the file being opened, as they are (not JSON), at most 128 MiB of them. @@ -615,12 +609,11 @@ An **importer** gets the bytes of the file being opened, as they are (not JSON), (absent: no handle), `kind` `"Smooth"` when the handles stay collinear. Coordinates are points, y down. - `fills`, `strokes`: the object's fills and strokes in paint order (bottom first); `null` is no paint. A stroke has its `paint`, `width` in points, `join` (`"miter"`, `"round"` or `"bevel"`), `cap` (`"butt"`, `"round"` or `"square"`) - and `miterLimit`. Paints use the - native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, `{"type": "gradient", "gradient": - {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, midpoint}], "dither"?, "method"?: "Perceptual"}, "geom"?, "angle", - "freeform"?}`, `{"type": "pattern", "pattern", "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk", - c, m, y, k}`, `{"model": "gray", k}` (ink: 0 white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except - Lab's. + and `miterLimit`. Paints use the native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, + `{"type": "gradient", "gradient": {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, + midpoint}], "dither"?, "method"?: "Perceptual"}, "geom"?, "angle", "freeform"?}`, `{"type": "pattern", "pattern", + "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk", c, m, y, k}`, `{"model": "gray", k}` (ink: 0 + white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except Lab's. - `effects` (filters): the object's own effects in order, as its effect records: `id` (as `effect.list` lists it, or `plugin.` for a live-effect plug-in), `params` and `visible`. Its fills' and strokes' own effects aren't listed. - `bounds`: `[x0, y0, x1, y1]`, the geometric bounds (read only). @@ -631,9 +624,9 @@ A filter whose manifest lists `inputs` also gets the selected objects of those k and under the same rules (hidden and locked ones, objects that clip a clip group, alone or inside a group that clips it, and anything inside a symbol, a placed document or a live object are left out). They are never turned into outlines, rectangles or the symbol's art. Each has `id`, `type`, `name` (when it has one), `bounds` (`null` until the -object has been laid out or drawn) and -`transform`, `[a, b, c, d, e, f]` mapping its own space (text space, pixel space, the symbol's art) into the document. -In this example the character and paragraph styles are cut short; a filter gets every attribute: +object has been laid out or drawn) and `transform`, `[a, b, c, d, e, f]` mapping its own space (text space, pixel space, +the symbol's art) into the document. In this example the character and paragraph styles are cut short; a filter gets +every attribute: ```json {"objects": [ @@ -662,13 +655,13 @@ In this example the character and paragraph styles are cut short; a filter gets `pixelsOmitted: false`. A CMYK image's pixels are its inks through the working CMYK profile, as sRGB. The pixels of all the images in one input take at most 16 MiB in base64, and an image the host starts to decode takes its size from those 16 MiB even when decoding fails. A larger image, one whose pixels don't fit what the images before - it in paint order left, and a linked image (`linked: true`) have - `pixelsOmitted: true` and no pixels; the host checks an image's size before it decodes the image. An image of any - size also has `pixelsOmitted: true` when its data is missing from the document, when its width or height is 0, when - its data cannot be decoded (including a non-CMYK image whose decoder enforces the host's 64 MiB memory limit and - needs more, and a TIFF the host rejects as too large), or when the dimensions in its data differ from `pixelSize`. - A plug-in checks `pixelsOmitted` before it reads `pixelsBase64`, which is present only when `pixelsOmitted` is `false`. The host never - reads a linked image's file for a filter and never gives its path. + it in paint order left, and a linked image (`linked: true`) have `pixelsOmitted: true` and no pixels; the host checks + an image's size before it decodes the image. An image of any size also has `pixelsOmitted: true` when its data is + missing from the document, when its width or height is 0, when its data cannot be decoded (including a non-CMYK image + whose decoder enforces the host's 64 MiB memory limit and needs more, and a TIFF the host rejects as too large), or + when the dimensions in its data differ from `pixelSize`. A plug-in checks `pixelsOmitted` before it reads + `pixelsBase64`, which is present only when `pixelsOmitted` is `false`. The host never reads a linked image's file for + a filter and never gives its path. - A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the document has no symbol of that name. `overrides` is `{}`. @@ -699,16 +692,16 @@ toward the 64 MiB output cap. ``` `mode` is `"filter"`, `"effect"`, `"export"`, `"import"` or `"command"` (a [command](#commands) run, whose -`command` is the command's id); `bounds` the selection's geometric bounds (a live effect: the reference -box of the effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard -under the selection's centre, else the first; `documentData` (filters) what the plug-in last kept with the document -(`null` until it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the -object's effects), or `{"fill": i}` / `{"stroke": i}` (in the own effects of the object's `i`th fill or stroke, -counted from the bottom as in `fills` and `strokes`). An export's `_context` is `{"mode": "export", "extension": "plt", -"name": "Poster"}` instead: the extension of the file being written (one the exporter lists) and its name without the -extension (the document's name when the export is not given a path), which some formats keep in their header. An -import's `_context` is `{"mode": "import", "extension": "plt", "name": "Poster"}`: the extension of the file being -opened and its name without the extension. +`command` is the command's id); `bounds` the selection's geometric bounds (a live effect: the reference box of the +effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard under the +selection's centre, else the first; `documentData` (filters) what the plug-in last kept with the document (`null` until +it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the object's effects), or +`{"fill": i}` / `{"stroke": i}` (in the own effects of the object's `i`th fill or stroke, counted from the bottom as in +`fills` and `strokes`). An export's `_context` is `{"mode": "export", "extension": "plt", "name": "Poster"}` instead: +the extension of the file being written (one the exporter lists) and its name without the extension (the document's name +when the export is not given a path), which some formats keep in their header. An import's `_context` is `{"mode": +"import", "extension": "plt", "name": "Poster"}`: the extension of the file being opened and its name without the +extension. ### Output @@ -785,17 +778,17 @@ is a piece drawn by itself, in the order returned, with paints of its own: path or compound path is hit-tested on its source path and appearance. On an object with no geometry effect of its own, Pathfinder effects and compound shapes use the object's path for an effect on a fill or stroke, as they do for every other effect on a fill or stroke; on an object that has one, they also use the pieces of a painted effect on a - fill or stroke. A 3D effect later in the stack uses the - object's input path when the pieces name a swatch or pattern. Each piece counts against the budget of the - object's geometry effects as well as its anchors, so all the painted effects in a stack make at most 100,000 pieces - together; a run whose pieces would pass the budget draws its input as it was. + fill or stroke. A 3D effect later in the stack uses the object's input path when the pieces name a swatch or pattern. + Each piece counts against the budget of the object's geometry effects as well as its anchors, so all the painted + effects in a stack make at most 100,000 pieces together; a run whose pieces would pass the budget draws its input as + it was. - The result is checked whole before any of it is drawn: the limits of a filter's output (100,000 objects, 4,000,000 anchors, 256 fills and 256 strokes per piece, 256 stops per gradient), finite numbers in range with nothing clamped (color components, tints, offsets, opacities, midpoints 0.13 to 0.87, stops in order of `offset`, stroke - widths 0 to 1,000 pt, miter limits 1 to 500), and every swatch a solid paint, gradient or stop links to and every pattern named in - the document. One fault refuses the whole result: that step of the stack draws its input as it was, the effect - record stays, and `plugin.info`'s `lastError` names the plug-in and the field, for example - `Test Tone: plug-in output: objects[1].strokes[0].width: …`. + widths 0 to 1,000 pt, miter limits 1 to 500), and every swatch a solid paint, gradient or stop links to and every + pattern named in the document. One fault refuses the whole result: that step of the stack draws its input as it was, + the effect record stays, and `plugin.info`'s `lastError` names the plug-in and the field, for example `Test Tone: + plug-in output: objects[1].strokes[0].width: …`. - The pieces are never written into the document while it is drawn, measured, previewed, saved or exported. PDF and SVG export draw them as the canvas does, from a copy of the document. Object › Expand Appearance writes each piece as an ordinary path or compound path with its fills and strokes, as one undo step. Object › Expand of gradient and pattern @@ -823,12 +816,13 @@ crates/plugins/example/build.sh # cargo build --release --target wasm32-unk The script also refreshes `crates/plugins/tests/fixtures/desaturate.wasm`, the test fixture built from that source. The crate is not a workspace member. Any language that targets core WebAssembly works (C, Zig, AssemblyScript, -WAT…), as long as the module imports nothing but the host imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never -touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, which the tests -build their plug-ins from; `wat::echo_exporter` is an exporter whose file is its input or its parameters (with a -base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with, and -`wat::when_given` a plug-in whose output depends on whether its input or parameters hold a given text, which shows in a -live effect's geometry. `wat::reporting` and `wat::cooperating` build level 2 plug-ins that import the host functions. +WAT…), as long as the module imports nothing but the host imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as +long as it never touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, +which the tests build their plug-ins from; `wat::echo_exporter` is an exporter whose file is its input or its parameters +(with a base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with, +and `wat::when_given` a plug-in whose output depends on whether its input or parameters hold a given text, which shows +in a live effect's geometry. `wat::reporting` and `wat::cooperating` build level 2 plug-ins that import the host +functions. ## Performance From 1bf09bf72e680ae7b0f3a278a37c8917dbb022b8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:24:22 -0800 Subject: [PATCH 44/79] SVG import: a of a text passes its text properties on to the text The element a shows inherits from the rather than from where it is defined (SVG 2, https://www.w3.org/TR/SVG2/struct.html#UseElement). A text shown by a now takes the font, size, weight, fill, stroke and other text properties the or its parents set, in clip paths too, while the text's own properties still win. Uses with the same parent and properties share one text. A inside the text it shows is ignored for inheritance, and texts built for elements stop at 1 MiB of text with a warning. --- crates/svg/src/import/text.rs | 299 +++++++++++++++++++++++++---- crates/svg/tests/it/clip_text.rs | 14 ++ crates/svg/tests/it/import_text.rs | 62 ++++++ 3 files changed, 338 insertions(+), 37 deletions(-) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 52b6663906..afd719163f 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -247,8 +247,9 @@ pub(super) fn prepare<'s>( && n.tag_name().namespace() == Some(SVG_NS) && !n.ancestors().any(|a| a.has_tag_name("style")) }; - // The slot of each text, by element, for the `` elements in clip paths below. + // The slot of each text, by element, for the `` elements below. let mut slot_of = HashMap::new(); + let mut inst = Instances::default(); for (t, style_t) in xml.descendants().filter(is_text).zip(style_xml.descendants().filter(is_text)) { let label = t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); // CSS Masking Module Level 1 §6.1 (https://www.w3.org/TR/css-masking-1/#ClipPathElement): @@ -275,20 +276,22 @@ pub(super) fn prepare<'s>( // A `` elsewhere can still show the text by its id: the clip path leaves out this // group (a group isn't clip path content), but the `` finds it. if t.attribute("id").is_some() { - placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints); + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints, true); } } else { - placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints); + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints, true); } copied = r.end; edits.push((r, out)); slot_of.insert(t.id(), index); slots.texts.push(text); + inst.paints.push(paints); } if slots.texts.is_empty() { return (Cow::Borrowed(svg), slots); } - clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut edits, warnings); + clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut inst, &mut edits, warnings); + text_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut inst, &mut edits, warnings); edits.sort_by_key(|(r, _)| r.start); let mut out = String::with_capacity(svg.len()); let mut copied = 0; @@ -332,6 +335,7 @@ fn clip_uses( cx: &Ctx, slot_of: &HashMap, slots: &mut TextSlots, + inst: &mut Instances, edits: &mut Vec<(Range, String)>, warnings: &mut Vec, ) { @@ -343,18 +347,16 @@ fn clip_uses( for (k, (u, style_u)) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)).enumerate() { let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue }; let Some(t) = ids.get(id) else { continue }; - let Some(&i) = slot_of.get(&t.id()) else { continue }; - let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue }; + let Some(&shared) = slot_of.get(&t.id()) else { continue }; let Some(q) = qualifier(svg, u) else { continue }; // An undisplayed `` adds nothing to the clip (usvg leaves it out as it is). if cx.css.own(style_u, "display").is_some_and(|d| d.trim() == "none") { continue; } let style_t = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name()); - let em = cx.font_size(style_u); - let length = |a: &str, pct: f64| u.attribute(a).and_then(|v| cx.length(v, em, pct)).unwrap_or(0.0); - let (x, y) = (length("x", cx.viewport.0), length("y", cx.viewport.1)); - let placed = format!("{} translate({x} {y})", own_transform(&cx.css, style_u)); + let i = style_t.map_or(shared, |s| use_slot(cx, style_xml, slots, inst, shared, s, style_u, warnings)); + let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue }; + let placed = use_placement(cx, u, style_u); let text_xf = match style_t { Some(s) => own_transform(&cx.css, s), None => t.attribute("transform").unwrap_or("").to_string(), @@ -386,6 +388,180 @@ fn clip_uses( } } +/// Replace each `` outside clip paths that shows a text it passes other properties on to by a +/// group holding that text's placeholder as the `` shows it ([`use_slot`]). Every other +/// `` of a text shows the text's own placeholder. +#[allow(clippy::too_many_arguments)] +fn text_uses( + svg: &str, + xml: &roxmltree::Document, + style_xml: &roxmltree::Document, + cx: &Ctx, + slot_of: &HashMap, + slots: &mut TextSlots, + inst: &mut Instances, + edits: &mut Vec<(Range, String)>, + warnings: &mut Vec, +) { + let is_use = |n: &XNode| { + n.is_element() + && n.tag_name().name() == "use" + && n.tag_name().namespace() == Some(SVG_NS) + && !n.ancestors().any(|a| a.is_element() && a.tag_name().name() == "clipPath") + }; + if !xml.descendants().any(|n| is_use(&n)) { + return; + } + let (ids, style_ids) = (first_ids(xml), first_ids(style_xml)); + for (u, style_u) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)) { + let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue }; + let Some(t) = ids.get(id) else { continue }; + let Some(&shared) = slot_of.get(&t.id()) else { continue }; + let Some(style_t) = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name()) else { continue }; + let Some(q) = qualifier(svg, u) else { continue }; + let i = use_slot(cx, style_xml, slots, inst, shared, style_t, style_u, warnings); + let (Some(text), Some(paints)) = (slots.texts.get(i), inst.paints.get(i)) else { continue }; + if i == shared { + continue; + } + let mut out = String::new(); + use_group(&mut out, q, cx, u, style_u); + placeholder(&mut out, q, &cx.css, *t, style_t, &slots.prefix, i, text.marker, paints, false); + let _ = write!(out, ""); + edits.push((u.range(), out)); + } +} + +/// A text's slot, the parent of a `` showing it and the properties that `` sets itself. +type UseKey = (usize, Option, Vec<(String, String)>); + +/// The slots made for texts as `` elements show them ([`use_slot`]). +#[derive(Default)] +struct Instances { + /// The `url(#…)` paints of each slot, by index. + paints: Vec>, + /// The slot made for each [`UseKey`]; `None` where the text's own slot serves. + memo: HashMap>, + /// The nodes and text bytes of the texts built so far, up to [`MAX_INSTANCE_WORK`]. + work: usize, +} + +/// The most nodes and text bytes [`use_slot`] builds texts from, all `` elements together +/// (the file is untrusted). Past it, a `` shows its text with the text's own properties. +const MAX_INSTANCE_WORK: usize = 1 << 20; + +/// The slot of text `style_t` (slot `shared`) as `` `style_u` shows it. The text inherits +/// from the `` there ([`Ctx::instance`]): when that changes it, it gets a new slot; otherwise +/// the result is `shared`. `` elements with one parent that set the same properties share one. +#[allow(clippy::too_many_arguments)] +fn use_slot( + cx: &Ctx, + style_xml: &roxmltree::Document, + slots: &mut TextSlots, + inst: &mut Instances, + shared: usize, + style_t: XNode, + style_u: XNode, + warnings: &mut Vec, +) -> usize { + // A `` inside the text it shows would make the text inherit from itself. + if style_u.ancestors().any(|a| a == style_t) { + return shared; + } + let placing = |name: &str| matches!(name, "id" | "x" | "y" | "width" | "height" | "href" | "transform" | "style"); + let mut own: Vec<(String, String)> = style_u + .attributes() + .filter(|a| a.namespace().is_none() && !placing(a.name())) + .map(|a| (a.name().to_string(), a.value().to_string())) + .collect(); + own.extend(cx.css.declared(style_u).iter().filter(|(k, _)| k.as_str() != "transform").map(|(k, v)| (format!("style {k}"), v.clone()))); + own.sort(); + let key = (shared, style_u.parent_element().map(|p| p.id()), own); + if let Some(slot) = inst.memo.get(&key) { + return slot.unwrap_or(shared); + } + let label = style_t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); + let work = if inst.work < MAX_INSTANCE_WORK { + style_t.descendants().map(|n| if n.is_text() { n.text().map_or(0, str::len) } else { 1 }).sum::() + } else { + 0 + }; + inst.work = inst.work.saturating_add(work); + if work == 0 || inst.work > MAX_INSTANCE_WORK { + inst.work = MAX_INSTANCE_WORK; + let w = format!( + "more than {} KiB of text shown through elements: the rest show their text without the properties the passes on", + MAX_INSTANCE_WORK >> 10 + ); + if !warnings.contains(&w) { + warnings.push(w); + } + return shared; + } + let shown = cx.instance(style_t, style_u); + let mut found = vec![]; + let built = if style_t.tag_name().name() == "flowRoot" { + shown.build_flow(style_t, &label, &mut found) + } else { + shown.build(style_xml, style_t, &label, &mut found) + }; + let differs = |(text, paints): &(PendingText, Vec)| { + slots.texts.get(shared).is_none_or(|old| old.obj != text.obj || old.servers != text.servers || old.under != text.under) + || inst.paints.get(shared) != Some(paints) + }; + let slot = built.filter(differs).map(|(text, paints)| { + for w in found { + if !warnings.contains(&w) { + warnings.push(w); + } + } + slots.texts.push(text); + inst.paints.push(paints); + slots.texts.len() - 1 + }); + inst.memo.insert(key, slot); + slot.unwrap_or(shared) +} + +/// The transform `` `u` places what it shows with: its own, then its `x`/`y` offset (in its +/// font size). `style_u` supplies its styles. +fn use_placement(cx: &Ctx, u: XNode, style_u: XNode) -> String { + let em = cx.font_size(style_u); + let length = |a: &str, pct: f64| u.attribute(a).and_then(|v| cx.length(v, em, pct)).unwrap_or(0.0); + let (x, y) = (length("x", cx.viewport.0), length("y", cx.viewport.1)); + format!("{} translate({x} {y})", own_transform(&cx.css, style_u)) +} + +/// Write the start tag of the group that takes `` `u`'s place: its attributes in no namespace +/// other than its link, place and size, and as its `style` the properties rules and its `style` set +/// on it (from `style_u`), then its placement ([`use_placement`]) and its non-inherited properties +/// and `visibility`, so no rule that matches the group changes it. +fn use_group(out: &mut String, q: &str, cx: &Ctx, u: XNode, style_u: XNode) { + let _ = write!(out, "<{q}g"); + let placing = |name: &str| matches!(name, "style" | "href" | "x" | "y" | "width" | "height" | "transform"); + for a in u.attributes().filter(|a| a.namespace().is_none() && !placing(a.name())) { + let _ = write!(out, " {}=\"{}\"", a.name(), xml_escape(a.value())); + } + let declared = cx.css.declared(style_u); + let mut props: Vec<_> = declared.iter().filter(|(k, _)| k.as_str() != "transform").collect(); + props.sort(); + let mut style: String = props.into_iter().map(|(k, v)| format!("{k}:{v};")).collect(); + let _ = write!(style, "transform:{};", use_placement(cx, u, style_u)); + for (p, initial) in [ + ("opacity", "1"), + ("clip-path", "none"), + ("mask", "none"), + ("filter", "none"), + ("mix-blend-mode", "normal"), + ("isolation", "auto"), + ("display", "inline"), + ] { + let _ = write!(style, "{p}:{};", cx.css.own(style_u, p).as_deref().unwrap_or(initial)); + } + let _ = write!(style, "visibility:{}", cx.css.prop(style_u, "visibility").as_deref().unwrap_or("visible")); + let _ = write!(out, " style=\"{}\">", xml_escape(&style)); +} + /// Write a copy of `` `u` in a ``: its attributes in no namespace, its link as `href`, /// and the properties set on it by rules and its `style` (from `style_u`) as its `style`. fn use_copy(out: &mut String, q: &str, css: &Styles, u: XNode, style_u: XNode) { @@ -455,11 +631,23 @@ fn clip_placeholder(out: &mut String, q: &str, id: &str, transform: &str, props: let _ = write!(out, " d=\"M{} {}H{}V{}H{}Z\"/>", b.x0, b.y0, b.x1, b.y1, b.x0); } -/// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`. +/// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`, with the text's +/// id when `with_id`. #[allow(clippy::too_many_arguments)] -fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode, prefix: &str, index: usize, b: Rect, paints: &[String]) { +fn placeholder( + out: &mut String, + q: &str, + css: &Styles, + t: XNode, + style_t: XNode, + prefix: &str, + index: usize, + b: Rect, + paints: &[String], + with_id: bool, +) { let _ = write!(out, "<{q}g"); - for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"] { + for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"].into_iter().skip(usize::from(!with_id)) { if let Some(v) = t.attribute(a) { let _ = write!(out, " {a}=\"{}\"", xml_escape(v)); } @@ -488,11 +676,15 @@ fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode /// Computed text properties of the document's elements. struct Ctx { - css: Styles, + css: Rc, /// Pixels per inch absolute lengths convert at ([`super::RootUnits::dpi`]). dpi: f64, /// Viewport size in user units (the base of `x`/`y` percentages). viewport: (f64, f64), + /// For the styles of one `` instance: the element the `` shows and the ``. The + /// shown element inherits from the `` rather than from its parent (SVG 2 §5.6, + /// https://www.w3.org/TR/SVG2/struct.html#UseElement). + host: Option<(roxmltree::NodeId, roxmltree::NodeId)>, sizes: RefCell>, weights: RefCell>, elems: RefCell>>, @@ -501,9 +693,10 @@ struct Ctx { impl Ctx { fn new(xml: &roxmltree::Document, dpi: f64) -> Self { let mut cx = Self { - css: Styles::new(xml), + css: Rc::new(Styles::new(xml)), dpi, viewport: (100.0, 100.0), + host: None, sizes: RefCell::default(), weights: RefCell::default(), elems: RefCell::default(), @@ -517,6 +710,37 @@ impl Ctx { cx } + /// The styles of element `shown` as `` `u` shows it. + fn instance(&self, shown: XNode, u: XNode) -> Self { + Self { + css: self.css.clone(), + dpi: self.dpi, + viewport: self.viewport, + host: Some((shown.id(), u.id())), + sizes: RefCell::default(), + weights: RefCell::default(), + elems: RefCell::default(), + } + } + + /// The element `n` inherits from: its parent, or the `` for the element it shows. + fn parent<'a, 'i>(&self, n: XNode<'a, 'i>) -> Option> { + match self.host { + Some((shown, u)) if n.id() == shown => n.document().get_node(u), + _ => n.parent_element(), + } + } + + /// Element `n` and the elements it inherits from, nearest first. + fn lineage<'a, 'i>(&self, n: XNode<'a, 'i>) -> impl Iterator> { + std::iter::successors(Some(n), |a| self.parent(*a)) + } + + /// An inherited property: the nearest specified value other than `inherit`. + fn prop(&self, n: XNode, name: &str) -> Option { + self.lineage(n).find_map(|a| self.css.own(a, name).filter(|v| v != "inherit")) + } + /// A length in user units: absolute units convert at [`Self::dpi`], `em` and `ex` are relative /// to `em` (the font size) and percentages to `pct`. fn length(&self, v: &str, em: f64, pct: f64) -> Option { @@ -559,7 +783,7 @@ impl Ctx { if let Some(s) = self.sizes.borrow().get(&n.id()) { return *s; } - let parent = n.parent_element().map_or(DEFAULT_FONT_SIZE, |p| self.font_size(p)); + let parent = self.parent(n).map_or(DEFAULT_FONT_SIZE, |p| self.font_size(p)); let s = self.css.own(n, "font-size").and_then(|v| self.parse_font_size(&v, parent)).unwrap_or(parent); self.sizes.borrow_mut().insert(n.id(), s); s @@ -570,7 +794,7 @@ impl Ctx { if let Some(w) = self.weights.borrow().get(&n.id()) { return *w; } - let parent = n.parent_element().map_or(400, |p| self.weight(p)); + let parent = self.parent(n).map_or(400, |p| self.weight(p)); let w = match self.css.own(n, "font-weight").as_deref() { Some("normal") => 400, Some("bold") => 700, @@ -594,12 +818,12 @@ impl Ctx { /// A fill or stroke: a ready paint, or a `url(#…)` paint for usvg to resolve. fn paint(&self, n: XNode, prop: &str) -> (Paint, Option) { let initial = if prop == "fill" { Paint::solid(Color::BLACK) } else { Paint::None }; - let Some(v) = self.css.prop(n, prop) else { return (initial, None) }; + let Some(v) = self.prop(n, prop) else { return (initial, None) }; match svgtypes::Paint::from_str(&v) { Ok(svgtypes::Paint::None) => (Paint::None, None), Ok(svgtypes::Paint::Color(c)) => (solid(c), None), Ok(svgtypes::Paint::CurrentColor) => { - (self.css.prop(n, "color").and_then(|c| svgtypes::Color::from_str(&c).ok()).map_or(Paint::solid(Color::BLACK), solid), None) + (self.prop(n, "color").and_then(|c| svgtypes::Color::from_str(&c).ok()).map_or(Paint::solid(Color::BLACK), solid), None) } Ok(svgtypes::Paint::FuncIRI(..)) => (Paint::None, Some(v)), _ => (initial, None), @@ -613,7 +837,7 @@ impl Ctx { } let css = &self.css; let mut st = CharStyle::default(); - if let Some(f) = css.prop(n, "font-family") { + if let Some(f) = self.prop(n, "font-family") { let fam = f.split(',').next().unwrap_or("").trim().trim_matches(|c| c == '\'' || c == '"').to_string(); if !fam.is_empty() { st.font_family = fam; @@ -621,45 +845,46 @@ impl Ctx { } let size = self.font_size(n); st.size = size; - let italic = css.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique"); + let italic = self.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique"); st.font_style = style_name(self.weight(n), italic); // Inherited: `inherit`, `unset`, `revert` and a value CSS ignores leave the one inherited. let inherits = |v: &String| ["inherit", "unset", "revert", "revert-layer"].iter().any(|k| v.trim().eq_ignore_ascii_case(k)); let own = |a: XNode| css.own(a, "font-variation-settings").filter(|v| !inherits(v)).and_then(|v| variation_settings(&v)); - st.font_axes = n.ancestors().filter(|a| a.is_element()).find_map(own).unwrap_or_default(); + st.font_axes = self.lineage(n).find_map(own).unwrap_or_default(); let (fill, mut fill_url) = self.paint(n, "fill"); st.fill = fill; let (stroke, mut stroke_url) = self.paint(n, "stroke"); if !stroke.is_none() || stroke_url.is_some() { st.stroke = stroke; - st.stroke_width = css.prop(n, "stroke-width").and_then(|w| self.length(&w, size, size)).unwrap_or(1.0); - st.stroke_cap = match css.prop(n, "stroke-linecap").as_deref() { + st.stroke_width = self.prop(n, "stroke-width").and_then(|w| self.length(&w, size, size)).unwrap_or(1.0); + st.stroke_cap = match self.prop(n, "stroke-linecap").as_deref() { Some("round") => LineCap::Round, Some("square") => LineCap::Square, _ => LineCap::Butt, }; - st.stroke_join = match css.prop(n, "stroke-linejoin").as_deref() { + st.stroke_join = match self.prop(n, "stroke-linejoin").as_deref() { Some("round") => LineJoin::Round, Some("bevel") => LineJoin::Bevel, _ => LineJoin::Miter, }; st.stroke_miter_limit = - css.prop(n, "stroke-miterlimit").and_then(|m| m.trim().parse::().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0)); - let pattern: Vec = css.prop(n, "stroke-dasharray").map_or(vec![], |d| list(&d).filter_map(|v| self.length(v, size, size)).collect()); - let offset = css.prop(n, "stroke-dashoffset").and_then(|o| self.length(&o, size, size)).unwrap_or(0.0); + self.prop(n, "stroke-miterlimit").and_then(|m| m.trim().parse::().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0)); + let pattern: Vec = + self.prop(n, "stroke-dasharray").map_or(vec![], |d| list(&d).filter_map(|v| self.length(v, size, size)).collect()); + let offset = self.prop(n, "stroke-dashoffset").and_then(|o| self.length(&o, size, size)).unwrap_or(0.0); st.stroke_dash = Some(Dash { pattern, offset, align_corners: false }).filter(Dash::is_dashed); } - if let Some(v) = css.prop(n, "letter-spacing").and_then(|v| self.length(&v, size, size)) + if let Some(v) = self.prop(n, "letter-spacing").and_then(|v| self.length(&v, size, size)) && size > 0.0 { st.tracking = v / size * 1000.0; } - if let Some(v) = css.prop(n, "kerning").and_then(|v| self.length(&v, size, size)) + if let Some(v) = self.prop(n, "kerning").and_then(|v| self.length(&v, size, size)) && size > 0.0 { st.kerning = Some(v / size * 1000.0); } - if let Some(d) = css.prop(n, "text-decoration") { + if let Some(d) = self.prop(n, "text-decoration") { st.underline = d.contains("underline"); st.strikethrough = d.contains("line-through"); } @@ -672,11 +897,11 @@ impl Ctx { }; let parent = if n.tag_name().name() == "text" { 0.0 } else { n.parent_element().map_or(0.0, |p| self.elem(p).style.baseline_shift) }; st.baseline_shift = parent + own; - if css.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") { + if self.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") { (st.fill, st.stroke, fill_url, stroke_url) = (Paint::None, Paint::None, None, None); } - let word_spacing = css.prop(n, "word-spacing").and_then(|v| self.length(&v, size, size)).unwrap_or(0.0); - let order = css.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order; + let word_spacing = self.prop(n, "word-spacing").and_then(|v| self.length(&v, size, size)).unwrap_or(0.0); + let order = self.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order; let at = |k| order.iter().position(|o| *o == k); let stroke_first = at(svgtypes::PaintOrderKind::Stroke) < at(svgtypes::PaintOrderKind::Fill); let e = Rc::new(ElemStyle { style: st, fill: fill_url, stroke: stroke_url, word_spacing, stroke_first }); @@ -974,7 +1199,7 @@ impl Ctx { .collect(); let under = stroke_under(&mut cells, label, warnings); - let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.css.prop(c.1, "text-anchor")).as_deref() { + let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.prop(c.1, "text-anchor")).as_deref() { Some("middle") => 0.5, Some("end") => 1.0, _ => 0.0, @@ -987,7 +1212,7 @@ impl Ctx { None } }); - let vertical = self.css.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); + let vertical = self.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); self.fit_lengths(&cs.spans, &mut cells, vertical, label, warnings); let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); let mut breaks: Vec = vec![]; @@ -1059,7 +1284,7 @@ impl Ctx { } // SVG text runs left to right unless `direction: rtl` says otherwise, whatever its first // strong character: pinned (on every paragraph) when that would read as right to left. - if self.css.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") { + if self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") { obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft)); } else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) { obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight)); diff --git a/crates/svg/tests/it/clip_text.rs b/crates/svg/tests/it/clip_text.rs index 07773dd5f8..a2dcb46156 100644 --- a/crates/svg/tests/it/clip_text.rs +++ b/crates/svg/tests/it/clip_text.rs @@ -183,6 +183,20 @@ fn a_use_of_a_text_in_a_clip_path_clips_where_the_use_places_it() { assert!(glyphs.x0 >= 200.0 - 1e-6 && glyphs.y1 <= 100.0 + 1e-6 && glyphs.y0 > 40.0, "{glyphs:?}"); } +#[test] +fn a_use_of_a_text_in_a_clip_path_passes_its_properties_on_to_the_text() { + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(near(t.runs[0].style.size, 100.0, 1e-9), "{}", t.runs[0].style.size); + assert_eq!(t.runs[0].style.font_style, "Bold"); + assert!(clip_bounds(clip).height() > 50.0, "{:?}", clip_bounds(clip)); +} + #[test] fn a_clip_path_on_a_use_of_a_text_is_in_the_use_s_user_space() { // The text's transform moves its glyphs to x 200…; the ``'s clip keeps x < 250 of its own diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 8b0d0338c6..87fe6cf340 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -191,6 +191,68 @@ fn hidden_text_is_skipped_and_use_instances_are_kept() { assert!(t[1].xf.translation().to_point().distance(Point::new(50.0, 30.0)) < 1e-9); } +/// SVG 2 §5.6 (https://www.w3.org/TR/SVG2/struct.html#UseElement): the element a `` shows +/// inherits from the ``, not from where it is defined. +#[test] +fn a_use_of_a_text_passes_its_properties_on_to_the_text() { + let d = open( + r##"HI + + "##, + ); + let t = texts(&d); + assert_eq!(t.len(), 3, "{t:?}"); + let s: Vec = t.iter().map(|t| t.first_style()).collect(); + assert!(close(s[0].size, 100.0, 1e-9), "{}", s[0].size); + assert_eq!(s[0].fill, Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0))); + assert!(close(s[1].size, 12.0, 1e-9), "the defaults, not the 's 30: {}", s[1].size); + assert_eq!(s[1].font_style, "Bold"); + assert_eq!(s[1].fill, Paint::solid(vectorcraft_color::Color::BLACK)); + assert!(close(s[2].size, 20.0, 1e-9), "{}", s[2].size); + assert!(t[0].xf.translation().to_point().distance(Point::new(0.0, 50.0)) < 1e-9, "{:?}", t[0].xf); + assert!(t[1].xf.translation().to_point().distance(Point::new(200.0, 50.0)) < 1e-9, "{:?}", t[1].xf); + assert!(t[2].xf.translation().to_point().distance(Point::new(0.0, 250.0)) < 1e-9, "{:?}", t[2].xf); + // The ``'s opacity stays on the object it becomes. + let faded: Vec<&Node> = art(&d).into_iter().filter(|n| (n.opacity - 0.5).abs() < 1e-6).collect(); + assert_eq!(faded.len(), 1, "{faded:?}"); + // The text's own properties win over the ``'s. + let d = open(r##"HI"##); + let s = only_text(&d).first_style(); + assert!(close(s.size, 20.0, 1e-9), "{}", s.size); + assert_eq!(s.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 255))); +} + +/// A `` inside the text it shows does not make the text inherit from itself. +#[test] +fn a_use_inside_the_text_it_shows_opens() { + let d = open(r##"HI"##); + assert!(texts(&d).iter().all(|t| close(t.first_style().size, 20.0, 1e-9)), "{:?}", texts(&d)); +} + +/// Past 1 MiB of text built for `` elements, the rest show their text with its own properties. +#[test] +fn uses_of_a_long_text_stop_taking_their_properties_past_the_limit() { + let long = "a".repeat(350_000); + let uses: String = (1..=4).map(|i| format!(r##""##, i * 10)).collect(); + let (d, w) = import_with_report(&svg(&format!(r##"{long}{uses}"##))).unwrap(); + let sizes: Vec = texts(&d).iter().map(|t| t.first_style().size).collect(); + assert_eq!(sizes.len(), 4, "{sizes:?}"); + assert!(close(sizes[0], 10.0, 1e-9) && close(sizes[1], 20.0, 1e-9), "{sizes:?}"); + assert!(close(sizes[2], 12.0, 1e-9) && close(sizes[3], 12.0, 1e-9), "{sizes:?}"); + assert_eq!(w.iter().filter(|w| w.contains("KiB of text shown through elements")).count(), 1, "{w:?}"); +} + +/// Many `` elements of one text that inherit the same properties each get it. +#[test] +fn many_uses_of_a_text_in_one_group_all_take_its_properties() { + let uses: String = (0..200).map(|i| format!(r##""##)).collect(); + let d = open(&format!(r##"HI{uses}"##)); + let t = texts(&d); + assert_eq!(t.len(), 200); + assert!(t.iter().all(|t| close(t.first_style().size, 40.0, 1e-9))); + assert!(t[199].xf.translation().to_point().distance(Point::new(199.0, 50.0)) < 1e-9, "{:?}", t[199].xf); +} + #[test] fn text_path_becomes_type_on_a_path() { let d = open( From c847acdf238ac24bcb0658e787badb0bfd48e3d7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:29:58 -0800 Subject: [PATCH 45/79] Palette: a pick opens the dialog its menu item opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pick in Search Commands ran its command with no params, while the menu bar item gives the command params and opens a dialog for them. Clean Up, Create Gradient Mesh, Smart Punctuation, Release Type on a Path and Deep Ungroup ran at once with their default values, Find and Replace reported "missing `find`", and Print Ink Fixes reported that it needs a value. A pick now runs what a click on the menu item runs, so these open their dialogs and nothing changes until the dialog is confirmed. While the menu item is dimmed (Remove Background or Object Mosaic with no image selected, Find and Replace with no document open), a pick and a shortcut open no dialog, and the status line says why the command can't run. Before this, a shortcut of such a command opened its dialog, and confirming it failed. Both routes use one helper, shortcut_editor::menu_target_now. The menu items that helper reads are read once. Items that list an installed plug-in (File › Open With importers, Object › Plug-ins filters) are now left out of them, so a plug-in installed or removed later does not leave an item there that the menu no longer shows. --- crates/ui-egui/src/menus.rs | 1 - crates/ui-egui/src/palette.rs | 166 ++++++++++++++++++++++++-- crates/ui-egui/src/shortcut_editor.rs | 55 +++++++-- crates/ui-egui/src/shortcuts.rs | 14 ++- crates/ui-egui/src/shortcuts/tests.rs | 45 +++++++ 5 files changed, 257 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1698e06ed4..5651a6ff34 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -71,7 +71,6 @@ pub(crate) use invoke::graph_dialog; #[cfg(test)] pub(crate) use invoke::with_held_keys; pub use invoke::{click_target, invoke, invoke_from_system_menu}; -#[cfg(test)] pub(crate) use labels::lists_plugin; #[cfg(test)] use labels::shows_a_name; diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index d5bf7b6098..1a58c496b2 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -91,7 +91,10 @@ fn build(lang: crate::i18n::Lang) -> Vec { } /// Runs the palette result `id`: selects a tool, or invokes a command with the params its entry -/// stands for. +/// stands for. A command whose menu bar item gives params runs what a click on that item runs +/// ([`crate::shortcut_editor::menu_target_now`]), so Clean Up… and Find and Replace… open their +/// dialogs. While that item is dimmed, no dialog opens and the status line says why the command +/// can't run. pub(crate) fn run_pick(app: &mut VectorcraftApp, id: &str) { if let Some(tool) = id.strip_prefix("tool:") { app.select_tool(tool); @@ -99,7 +102,11 @@ pub(crate) fn run_pick(app: &mut VectorcraftApp, id: &str) { let command = crate::keymap::command_of(id); menus::invoke(app, &command.id, command.params); } else { - menus::invoke(app, id, json!({})); + match crate::shortcut_editor::menu_target_now(app, id) { + Some(Ok((id, p))) => menus::invoke(app, &id, p), + Some(Err(why)) => app.status(why), + None => menus::invoke(app, id, json!({})), + } } } @@ -217,7 +224,7 @@ mod tests { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); let before = app.session.prefs.use_precise_cursors; let first = matches.first().expect("query has a result"); - menus::invoke(&mut app, &first.id, json!({})); + run_pick(&mut app, &first.id); assert_ne!(app.session.prefs.use_precise_cursors, before, "first result toggles the preference"); } @@ -238,8 +245,7 @@ mod tests { app.run("select.set", json!({"ids": [a, b]})).unwrap(); app.run("select.key", json!({"id": b})).unwrap(); assert!(app.session.active().unwrap().selection.key.is_some()); - // Run a pick the way `show` runs it. - menus::invoke(&mut app, ids[0], json!({})); + run_pick(&mut app, ids[0]); let st = app.session.active().unwrap(); assert_eq!((st.selection.len(), st.selection.key), (2, None), "both objects stay selected, without a key"); } @@ -255,20 +261,162 @@ mod tests { app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); app.ui.status.clear(); assert!(!crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}"); - // Run a pick the way `show` runs it. - menus::invoke(&mut app, id, json!({})); + run_pick(&mut app, id); assert!(crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}: {}", app.ui.status); assert!(!app.ui.status.contains("invalid parameters"), "{id}: {}", app.ui.status); // A second pick leaves the open panel open, docked or floating. - menus::invoke(&mut app, id, json!({})); + run_pick(&mut app, id); assert!(crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}: picked twice"); app.run("window.panel.float", json!({"panel": crate::panels::asset_export::ID})).unwrap(); assert!(crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}: floating"); - menus::invoke(&mut app, id, json!({})); + run_pick(&mut app, id); assert!(crate::panel_docking::showing(&app.ui, crate::panels::asset_export::ID), "{id}: floating, picked again"); } } + /// The open dialog's kind and fields, the active document's undo depth and the status line. + type Outcome = (Option<(String, serde_json::Map)>, Option, String); + + /// The [`Outcome`] `app` shows. + fn outcome(app: &VectorcraftApp) -> Outcome { + ( + app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.fields.clone())), + app.session.active().map(|st| st.history.undo.len()), + app.ui.status.clone(), + ) + } + + /// A pick of a "…" command whose menu bar item gives params opens that item's dialog, and Smooth + /// opens its slider; nothing changes until the dialog is confirmed. + #[test] + fn a_pick_opens_the_dialog_its_menu_item_opens() { + let expected = [ + ("object.path.cleanUp", "command"), + ("object.mesh.create", "command"), + ("type.smartPunctuation", "command"), + ("edit.findReplace", "command"), + ("object.path.smooth", crate::dialogs::smooth_slider::KIND), + ]; + for (id, kind) in expected { + assert!(items().iter().any(|(_, item, _)| item == id), "{id} is in the palette"); + let mut app = VectorcraftApp::new(vectorcraft_testkit::fixtures::Fixture::Single.session(), crate::Services::default()); + app.ui.status.clear(); + let before = app.session.active().unwrap().history.undo.len(); + run_pick(&mut app, id); + let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("{id} opens its dialog: {}", app.ui.status)); + assert_eq!(d.kind, kind, "{id}"); + if kind == "command" { + assert_eq!(d.fields["__command"], json!(id), "{id}: {:?}", d.fields); + } + assert_eq!(app.session.active().unwrap().history.undo.len(), before, "{id} changes nothing yet"); + assert!(app.ui.status.is_empty(), "{id}: {}", app.ui.status); + } + } + + /// Every palette command whose menu bar items all give it the same params does what a click on + /// such an item does, as the menu row shows it ([`menus::entry`]): with no document open, in an + /// empty document and with one or several objects selected, the pick opens the same dialog with + /// the same fields and leaves the same undo steps and status. While the row is dimmed, the pick + /// opens no dialog, changes nothing and says why the command can't run. + #[test] + fn every_pick_runs_what_its_menu_item_runs() { + use vectorcraft_testkit::fixtures::Fixture; + fn walk<'a>(items: &'a [menus::Item], out: &mut Vec<&'a menus::Item>) { + for it in items { + match it { + menus::Item::Sub(_, children) => walk(children, out), + // Items that list an installed plug-in come and go as tests beside this one + // install plug-ins, and no shortcut or pick reads them. + menus::Item::Cmd(_, id, p) if !menus::lists_plugin(id, p) => out.push(it), + _ => {} + } + } + } + let tree = menus::menu_tree(); + let mut rows = vec![]; + for (_, items) in &tree { + walk(items, &mut rows); + } + let mut checked: Vec<(String, &menus::Item)> = vec![]; + for (_, id, _) in items() { + let of_id: Vec<&menus::Item> = rows.iter().copied().filter(|it| matches!(it, menus::Item::Cmd(_, c, _) if *c == id)).collect(); + let params: Vec<&serde_json::Value> = + of_id.iter().filter_map(|it| if let menus::Item::Cmd(_, _, p) = it { Some(p) } else { None }).collect(); + let Some(first) = params.first() else { continue }; + if first.as_object().is_none_or(serde_json::Map::is_empty) || params.iter().any(|p| p != first) { + continue; + } + checked.push((id, of_id[0])); + } + assert!(checked.iter().any(|(id, _)| id == "edit.findReplace"), "{:?}", checked.iter().map(|c| &c.0).collect::>()); + let state = |n: usize| match n.checked_sub(1) { + None => vectorcraft_engine::Session::new(), + Some(k) => Fixture::ALL[k].session(), + }; + let (mut compared, mut dimmed) = (0, 0); + for (id, item) in &checked { + for n in 0..=Fixture::ALL.len() { + let mut picked = VectorcraftApp::new(state(n), crate::Services::default()); + let mut clicked = VectorcraftApp::new(state(n), crate::Services::default()); + picked.ui.status.clear(); + clicked.ui.status.clear(); + // A row the menu leaves out in this state has no click to compare with. + let Some(menus::Entry::Item(row)) = menus::entry(&clicked, item) else { continue }; + let (target, enabled) = (row.target().expect("the item is implemented"), row.enabled); + let before = outcome(&picked); + run_pick(&mut picked, id); + if enabled { + menus::invoke(&mut clicked, &target.0, target.1); + assert_eq!(outcome(&picked), outcome(&clicked), "{id} [state {n}]"); + compared += 1; + } else { + let after = outcome(&picked); + assert_eq!((after.0, after.1), (None, before.1), "{id} [state {n}] is dimmed"); + assert!(after.2.contains("not available"), "{id} [state {n}]: {}", after.2); + dimmed += 1; + } + } + } + assert!(compared > 0 && dimmed > 0, "{compared} compared, {dimmed} dimmed"); + } + + /// A pick of a "…" command whose menu bar item is dimmed (Remove Background and Object Mosaic + /// with no image selected, Find and Replace with no document open) opens no dialog, changes + /// nothing and reports why the command can't run. + #[test] + fn a_pick_of_a_dimmed_command_says_why_it_cannot_run() { + let cases = [ + ("addon.image.removeBackground", vectorcraft_testkit::fixtures::Fixture::Multi.session()), + ("object.createObjectMosaic", vectorcraft_testkit::fixtures::Fixture::Multi.session()), + ("edit.findReplace", vectorcraft_engine::Session::new()), + ]; + for (id, session) in cases { + assert!(crate::shortcut_editor::menu_target(id).is_some(), "{id} has a dialog"); + let mut app = VectorcraftApp::new(session, crate::Services::default()); + assert!(!menus::enabled(&app, id), "{id}"); + app.ui.status.clear(); + let before = app.session.active().map(|st| st.history.undo.len()); + run_pick(&mut app, id); + assert!(app.ui.dialog.is_none(), "{id}"); + assert_eq!(app.session.active().map(|st| st.history.undo.len()), before, "{id}"); + assert!(app.ui.status.contains("not available"), "{id}: {}", app.ui.status); + } + } + + /// A pick of a command that opens no dialog runs it at once. + #[test] + fn a_pick_of_a_command_without_a_dialog_runs_at_once() { + for id in ["object.group", "paint.swap"] { + assert!(items().iter().any(|(_, item, _)| item == id), "{id} is in the palette"); + assert_eq!(crate::shortcut_editor::menu_target(id), None, "{id}"); + let mut app = VectorcraftApp::new(vectorcraft_testkit::fixtures::Fixture::Multi.session(), crate::Services::default()); + let before = app.session.active().unwrap().history.undo.len(); + run_pick(&mut app, id); + assert!(app.ui.dialog.is_none(), "{id}"); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1, "{id}: {}", app.ui.status); + } + } + /// #1757: Up and Down move the highlighted result, wrapping at the ends; a new query starts /// from the first again. #[test] diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 7ada99ee5c..a580b2255a 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -653,23 +653,28 @@ const NEEDS_A_PARAM: [&str; 20] = [ /// The (label, params) of each menu bar item, by command id. fn menu_bar_items() -> &'static HashMap<&'static str, Vec<(&'static str, Value)>> { + static ITEMS: OnceLock>> = OnceLock::new(); + ITEMS.get_or_init(|| bar_items(&menus::menu_tree())) +} + +/// The (label, params) of each item of the menus `tree`, by command id. Items that list an +/// installed plug-in ([`menus::lists_plugin`]) are left out: [`menu_bar_items`] is built once, +/// and a plug-in installed or removed later would leave it with a row the menu no longer shows. +fn bar_items(tree: &[(&'static str, Vec)]) -> HashMap<&'static str, Vec<(&'static str, Value)>> { fn walk(items: &[menus::Item], out: &mut HashMap<&'static str, Vec<(&'static str, Value)>>) { for it in items { match it { - menus::Item::Cmd(label, id, p) => out.entry(*id).or_default().push((*label, p.clone())), + menus::Item::Cmd(label, id, p) if !menus::lists_plugin(id, p) => out.entry(*id).or_default().push((*label, p.clone())), menus::Item::Sub(_, children) => walk(children, out), _ => {} } } } - static ITEMS: OnceLock>> = OnceLock::new(); - ITEMS.get_or_init(|| { - let mut out = HashMap::new(); - for (_, items) in menus::menu_tree() { - walk(&items, &mut out); - } - out - }) + let mut out = HashMap::new(); + for (_, items) in tree { + walk(items, &mut out); + } + out } fn is_bare(p: &Value) -> bool { @@ -686,6 +691,19 @@ pub(crate) fn menu_target(id: &str) -> Option<(String, Value)> { items.iter().all(|(_, q)| q == p).then(|| menus::click_target(label, id, p)) } +/// What a command search pick, or a shortcut without params, of command `id` runs now: `Ok` with +/// what a click on its menu bar item runs ([`menu_target`]), or `None` when no such item gives it +/// params. While that item is dimmed, no dialog opens for it: `Err` holds the status line that says +/// why the command can't run (Remove Background… with no image selected). +pub(crate) fn menu_target_now(app: &VectorcraftApp, id: &str) -> Option> { + let target = menu_target(id)?; + if menus::enabled(app, id) { + return Some(Ok(target)); + } + let why = (vectorcraft_engine::find_command(id)?.enabled)(&app.session).err()?; + Some(Err(vectorcraft_engine::EngineError::Disabled(id.to_string(), why).to_string())) +} + /// Is `key` a command with no default shortcut that a key press can't run: no item of the menu /// bar runs it without params or with one set of params ([`menu_target`]), it opens no dialog when /// run without them (as the Variables panel's commands do), and it is a query, whose answer a key @@ -2270,6 +2288,25 @@ mod tests { assert_eq!(menu_target("edit.undo"), None, "runs without params"); } + /// The menu bar items a shortcut or a command search pick reads leave out items that list an + /// installed plug-in (a File › Open With importer, an Object › Plug-ins filter), which come and + /// go after the items are read; the other items stay. + #[test] + fn menu_bar_items_leave_out_installed_plug_ins() { + let tree = vec![( + "File", + vec![ + menus::Item::Sub("Open With", vec![menus::Item::Cmd("Spins…", "file.openWith", json!({"importer": "plugin.test.spins"}))]), + menus::Item::Cmd("Filter…", "plugin.dialog", json!({"id": "test.filter"})), + menus::Item::Cmd("Clean Up…", "object.path.cleanUp", json!({"strayPoints": true})), + ], + )]; + let items = bar_items(&tree); + assert!(!items.contains_key("file.openWith"), "{items:?}"); + assert!(!items.contains_key("plugin.dialog"), "{items:?}"); + assert_eq!(items.get("object.path.cleanUp"), Some(&vec![("Clean Up…", json!({"strayPoints": true}))])); + } + /// Every command the editor lists, run as a key press runs it on a document with one or with /// several objects selected, reports no missing param. #[test] diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 83d53d700c..f0ff34ef64 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -985,9 +985,10 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, accept: impl FnMut(&H } /// What a shortcut without params runs when its command's menu bar item gives params -/// ([`crate::shortcut_editor::menu_target`]): what a click on that item runs. -fn menu_target(c: &CommandRef) -> Option<(String, serde_json::Value)> { - c.params.as_object().is_none_or(serde_json::Map::is_empty).then(|| crate::shortcut_editor::menu_target(&c.id)).flatten() +/// ([`crate::shortcut_editor::menu_target_now`]): what a click on that item runs, or while the item +/// is dimmed, why the command can't run. +fn menu_target(app: &VectorcraftApp, c: &CommandRef) -> Option> { + c.params.as_object().is_none_or(serde_json::Map::is_empty).then(|| crate::shortcut_editor::menu_target_now(app, &c.id)).flatten() } /// Params used when a shortcut invokes its command. The named panel function keys retain their @@ -1030,8 +1031,11 @@ fn run_hit(app: &mut VectorcraftApp, ctx: &egui::Context, c: &CommandRef) { // every artboard instead of the art. crate::panels::artboards::select_all(app); } else { - let (id, p) = menu_target(c).unwrap_or_else(|| (c.id.clone(), shortcut_params(c))); - crate::menus::invoke(app, &id, p); + match menu_target(app, c) { + Some(Ok((id, p))) => crate::menus::invoke(app, &id, p), + Some(Err(why)) => app.status(why), + None => crate::menus::invoke(app, &c.id, shortcut_params(c)), + } } } diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 8d32a4de22..bdc6efbc52 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -412,6 +412,28 @@ fn a_single_key_shortcut_without_a_dialog_runs_at_once() { assert_eq!((open_dialog(&app), undo_len(&app)), (None, before + 1), "Shift+X swaps fill and stroke: {}", app.ui.status); } +/// A key or a chord of a command whose menu item is dimmed opens no dialog: Remove Background… and +/// Object Mosaic… with a rectangle selected change nothing and say why they can't run. +#[test] +fn a_shortcut_of_a_dimmed_command_opens_no_dialog_and_says_why() { + let chord = vec![ + egui::Event::ModifiersChanged(cmd() | Modifiers::ALT | Modifiers::SHIFT), + egui::Event::Key { key: Key::F9, physical_key: None, pressed: true, repeat: false, modifiers: cmd() | Modifiers::ALT | Modifiers::SHIFT }, + ]; + for id in ["addon.image.removeBackground", "object.createObjectMosaic"] { + assert!(crate::shortcut_editor::menu_target(id).is_some(), "{id} has a dialog"); + for (shortcut, events) in [("F", press_f()), ("Cmd+Alt+Shift+F9", chord.clone())] { + let mut app = app_with_shortcut(id, shortcut); + assert!(!crate::menus::enabled(&app, id), "{id}"); + let before = undo_len(&app); + frame(&mut app, events); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert_eq!((open_dialog(&app), undo_len(&app)), (None, before), "{id} on {shortcut}: {}", app.ui.status); + assert!(app.ui.status.contains("not available"), "{id} on {shortcut}: {}", app.ui.status); + } + } +} + /// With no document open, a command's key without a modifier reports what the same command /// reports on a chord. #[test] @@ -1616,6 +1638,29 @@ fn the_command_search_that_opens_while_enter_is_down_waits_for_a_new_press() { assert!(!app.ui.palette_open, "a new press runs the highlighted result"); } +/// Enter on a command search result whose menu item opens a dialog (Create Gradient Mesh…) opens +/// that dialog: the press and its repeats do not confirm it, and a new press does. +#[test] +fn enter_on_a_command_search_result_opens_its_dialog_and_a_new_press_confirms_it() { + let mut app = app_with_a_selected_rectangle(); + let held = HeldKey::new(Key::Enter, Modifiers::NONE); + app.select_tool("selection"); + app.ui.palette_open = true; + app.ui.palette_query = "object.mesh.create".into(); + let before = undo_len(&app); + for repeat in 0..4 { + held.press(&mut app); + let command = app.ui.dialog.as_ref().and_then(|d| d.fields.get("__command").cloned()); + assert_eq!(command, Some(json!("object.mesh.create")), "repeat {repeat}: its dialog stays open: {}", app.ui.status); + assert_eq!(undo_len(&app), before, "repeat {repeat}: nothing changed"); + } + assert!(!app.ui.palette_open); + held.release(&mut app); + held.press(&mut app); + assert_eq!(open_dialog(&app), None, "a new press confirms"); + assert_eq!(undo_len(&app), before + 1, "the mesh is one undo step: {}", app.ui.status); +} + /// A field that rejects its text on Enter keeps the dialog open, while the key is held too. #[test] fn holding_enter_on_a_rejected_value_leaves_the_dialog_open() { From f317aefa442a8f17c13909b4c6204089dbfab3b7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:32:35 -0800 Subject: [PATCH 46/79] Tests: the file-dialog menu test reruns while fonts or plug-ins change `the_menu_bar_is_inert_while_a_file_dialog_is_open` excused a missing menu row when the composite font or plug-in it named was gone at the time of the check. A test running alongside can remove such a font and define it again between the menu read and the check, and the test then failed. It now runs inside `undisturbed`, as the file's other tests that compare menu reads do. When the shortcut, plug-in or font generation changes during a run, the test runs again on a new app. Every row from before the dialog must then be back as it was. --- crates/ui-egui/src/tests/nativemenu.rs | 108 ++++++++++--------------- 1 file changed, 44 insertions(+), 64 deletions(-) diff --git a/crates/ui-egui/src/tests/nativemenu.rs b/crates/ui-egui/src/tests/nativemenu.rs index a5a8ea0d7a..689ae8a60c 100644 --- a/crates/ui-egui/src/tests/nativemenu.rs +++ b/crates/ui-egui/src/tests/nativemenu.rs @@ -69,25 +69,6 @@ fn find<'a>(bar: &'a MenuBar, command: &str) -> &'a native_menu::Item { bar.items().into_iter().find(|it| it.command == Some(command)).unwrap_or_else(|| panic!("no {command} item")) } -/// Is the font or plug-in that an item runs `command` with `params` for gone from the process-wide -/// font database or plug-in registry? A test running alongside can remove a composite font or a -/// plug-in, and the menus then drop its item. -fn source_gone(command: Option<&str>, params: &Value) -> bool { - use vectorcraft_plugins::{PREFIX, effect, export, registry}; - let field = |key| params.get(key).and_then(Value::as_str); - let plugin_gone = |id: Option<&str>| id.is_some_and(|id| registry::get(id).is_none()); - match command { - Some("text.setStyle") => field("font").is_some_and(|f| !vectorcraft_text::FontDb::global().menu_family_list_now().iter().any(|x| x == f)), - Some("plugin.dialog") => plugin_gone(field("id")), - Some("file.openWith") => plugin_gone(field("importer").and_then(export::plugin_id)), - Some("effect.apply" | "effect.dialog") => plugin_gone(field("effect").and_then(effect::plugin_id)), - Some(c) if vectorcraft_engine::cmd::plugin::is_command_id(c) => { - plugin_gone(c.strip_prefix(PREFIX).and_then(|rest| rest.rsplit_once('.')).map(|(id, _)| id)) - } - _ => false, - } -} - #[test] fn vectorcraft_gets_a_mac_app_menu() { let bar = bar(&app(true)); @@ -702,52 +683,51 @@ fn the_app_bar_hides_its_menus_with_the_mac_menu_bar() { /// answers, the menus are as before and work again. #[test] fn the_menu_bar_is_inert_while_a_file_dialog_is_open() { - let mut m = Mac::new(true); - m.app.services.dialogs_are_sheets = true; - let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut m.app.services); - m.app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap(); - m.app.run("select.none", json!({})).unwrap(); - m.frame(vec![]); let rows = |bar: &MenuBar| bar.items().iter().map(|it| (it.command, it.params.clone(), it.enabled)).collect::>(); - let before = m.fake.last(); - let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE]; - assert!(system.iter().all(|c| find(&before, c).enabled)); - assert!(m.app.run("file.open", json!({})).is_err()); - assert!(m.app.file_dialog_open()); - m.frame(vec![]); - let inert = m.fake.last(); - for it in inert.items() { - let kept = it.command.is_some_and(|c| system.contains(&c)); - assert_eq!(it.enabled, kept, "{:?} {} while the dialog is open", it.command, it.label); - } - // A click that raced the menu going inert isn't run; Minimize is. - let rulers = (m.app.ui.view.rulers, find(&before, "view.rulers").clone()); - m.fake.report(native_menu::Event::Click(rulers.1.clone())); - m.frame(vec![]); - assert_eq!(m.app.ui.view.rulers, rulers.0); - m.fake.report(native_menu::Event::Click(find(&inert, native_menu::MINIMIZE).clone())); - assert!(m.frame(vec![]).viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Minimized(true))); - // A key equivalent that raced it doesn't reach egui. - let mut k = crate::shortcuts::parse("Cmd+A").unwrap(); - k.modifiers.mac_cmd = k.modifiers.command; - m.fake.report(native_menu::Event::Key(k)); - let mut raw = egui::RawInput::default(); - m.app.raw_input_hook(&m.ctx, &mut raw); - assert!(!raw.events.iter().any(|e| matches!(e, Event::Key { .. })), "{:?}", raw.events); - m.key_equivalent("Cmd+A"); - assert_eq!(m.selected(), 0, "⌘A selected nothing"); - // Cancelled: the menus are as before, and work again. - shown.answer(&[]); - m.frame(vec![]); - m.frame(vec![]); - assert!(!m.app.file_dialog_open()); - // Every row from before is back as it was. Tests running alongside may add rows, and may - // remove the composite font or plug-in a row is for. - let after = rows(&m.fake.last()); - for r in rows(&before) { - let listed = after.iter().any(|a| (a.0, &a.1) == (r.0, &r.1)); - assert!(after.contains(&r) || (!listed && source_gone(r.0, &r.1)), "{r:?} is not as before"); - } + // The menus read before the dialog and after it are compared, so the whole run is undisturbed: + // a test running alongside can add or remove a composite font or a plug-in in between. + let (mut m, (before, after, rulers)) = undisturbed(|m| { + m.app.services.dialogs_are_sheets = true; + let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut m.app.services); + m.app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap(); + m.app.run("select.none", json!({})).unwrap(); + m.frame(vec![]); + let before = m.fake.last(); + let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE]; + assert!(system.iter().all(|c| find(&before, c).enabled)); + assert!(m.app.run("file.open", json!({})).is_err()); + assert!(m.app.file_dialog_open()); + m.frame(vec![]); + let inert = m.fake.last(); + for it in inert.items() { + let kept = it.command.is_some_and(|c| system.contains(&c)); + assert_eq!(it.enabled, kept, "{:?} {} while the dialog is open", it.command, it.label); + } + // A click that raced the menu going inert isn't run; Minimize is. + let rulers = (m.app.ui.view.rulers, find(&before, "view.rulers").clone()); + m.fake.report(native_menu::Event::Click(rulers.1.clone())); + m.frame(vec![]); + assert_eq!(m.app.ui.view.rulers, rulers.0); + m.fake.report(native_menu::Event::Click(find(&inert, native_menu::MINIMIZE).clone())); + assert!(m.frame(vec![]).viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Minimized(true))); + // A key equivalent that raced it doesn't reach egui. + let mut k = crate::shortcuts::parse("Cmd+A").unwrap(); + k.modifiers.mac_cmd = k.modifiers.command; + m.fake.report(native_menu::Event::Key(k)); + let mut raw = egui::RawInput::default(); + m.app.raw_input_hook(&m.ctx, &mut raw); + assert!(!raw.events.iter().any(|e| matches!(e, Event::Key { .. })), "{:?}", raw.events); + m.key_equivalent("Cmd+A"); + assert_eq!(m.selected(), 0, "⌘A selected nothing"); + // Cancelled: the menus are as before, and work again. + shown.answer(&[]); + m.frame(vec![]); + m.frame(vec![]); + assert!(!m.app.file_dialog_open()); + (rows(&before), rows(&m.fake.last()), rulers) + }); + // Every row from before is back as it was. + assert!(before.iter().all(|r| after.contains(r)), "the menus are as before"); m.fake.report(native_menu::Event::Click(rulers.1)); m.frame(vec![]); assert_eq!(m.app.ui.view.rulers, !rulers.0); From 0b22997201519256aabf5de71c9c33aaf98a9a38 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:33:38 -0800 Subject: [PATCH 47/79] Docs: the Dynamic Text entries in docs/mcp.md name point type The Dynamic Text: Block entry says the Contextual Task Bar offers the item for point type too, and that turning it on makes point type area type with Block on. The Dynamic Text entry no longer says point type in the selection is left as it is. --- docs/mcp.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 4a5170de26..4449f69897 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7090,8 +7090,8 @@ chosen artboards draw all of them. A PDF leaves out the layers that don't print ### Dynamic Text: Block `type.dynamicText {ids?|id?, on?: bool, wordGap?, lineGap?}` (Type › Dynamic Text, and the Contextual Task Bar for -area type outside a thread) turns Block on or off for the -selected area type, as it turns Dynamic Text on or off for type on a path ([Dynamic Text](#dynamic-text)). Area Type +area type outside a thread and for point type) turns Block on or off for the +selected area type, and turning it on makes point type area type with Block on (below), as it turns Dynamic Text on or off for type on a path ([Dynamic Text](#dynamic-text)). Area Type Options › Block is the same setting, and the Properties panel's Paragraph section has a Dynamic Text checkbox, which shows Word Gap and Line Gap while it is on. The [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html) describes Block as text that scales to fill a rectangular box, with settings for the spacing between words and between @@ -7970,8 +7970,8 @@ and tate-chu-yoko offsets by one factor, so runs keep their proportions; Type on scaled. The first character's size, in document points, moves in steps of 0.1 pt within 0.1 to 1,296 pt; text that still runs past the end bracket at 0.1 pt overflows. Reshaping or scaling the path fits the text again. The stored sizes do not change: turning Dynamic Text off writes the sizes the text was drawn at into its characters, so it keeps -its look, in one undo step. Point type in the selection is left as it is, and area type in it takes Block -([Dynamic Text: Block](#dynamic-text-block)). Type on a path in a +its look, in one undo step. Area type in the selection takes Block, and turning it on makes point type in the +selection area type with Block on ([Dynamic Text: Block](#dynamic-text-block)). Type on a path in a thread is refused with an error that names it, and the menu item and the task bar item are not offered for it; `text.thread.create` and `text.thread.add` turn Dynamic Text off on the frames they thread, at their drawn sizes, and list them in `dynamicTextOff`; `addon.type.releasePath` turns it off at the drawn sizes on the point type it makes. When every object already has the setting asked for, nothing changes and no undo step From 909f0ff301802754737138f3442d8cd2502e7974 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:43:42 -0800 Subject: [PATCH 48/79] SVG import: the path a text follows counts toward the text limit A text on a path that many elements show was laid out along the whole path once per , and the path was found in the file and parsed again each time, while the 1 MiB limit on texts built for elements counted only the text's nodes and characters. A file with a short text on a very long path and many elements of it could keep the importer busy far past that limit. - The elements of the path a follows now count toward the limit. Past it, the remaining elements show the text with its own properties, and the warning names text paths as well as text. - The path of each is found and parsed once and shared by every that shows its text. Files within the limit import as before. --- crates/svg/src/import/text.rs | 54 +++++++++++++++++++++++++---- crates/svg/src/import/text/tests.rs | 24 +++++++++++++ crates/svg/tests/it/import_text.rs | 23 +++++++++++- 3 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 crates/svg/src/import/text/tests.rs diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index afd719163f..51bcffa41e 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -442,12 +442,14 @@ struct Instances { paints: Vec>, /// The slot made for each [`UseKey`]; `None` where the text's own slot serves. memo: HashMap>, - /// The nodes and text bytes of the texts built so far, up to [`MAX_INSTANCE_WORK`]. + /// The nodes, text bytes and text path elements of the texts built so far, up to + /// [`MAX_INSTANCE_WORK`]. work: usize, } -/// The most nodes and text bytes [`use_slot`] builds texts from, all `` elements together -/// (the file is untrusted). Past it, a `` shows its text with the text's own properties. +/// The most nodes, text bytes and elements of the paths ``s follow that [`use_slot`] +/// builds texts from, all `` elements together (the file is untrusted). Past it, a `` +/// shows its text with the text's own properties. const MAX_INSTANCE_WORK: usize = 1 << 20; /// The slot of text `style_t` (slot `shared`) as `` `style_u` shows it. The text inherits @@ -482,7 +484,11 @@ fn use_slot( } let label = style_t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); let work = if inst.work < MAX_INSTANCE_WORK { - style_t.descendants().map(|n| if n.is_text() { n.text().map_or(0, str::len) } else { 1 }).sum::() + // Each build lays the text out along the path its `` follows, so the path counts too. + style_t + .descendants() + .map(|n| if n.is_text() { n.text().map_or(0, str::len) } else { cx.path_elements(n).saturating_add(1) }) + .fold(0, usize::saturating_add) } else { 0 }; @@ -490,7 +496,7 @@ fn use_slot( if work == 0 || inst.work > MAX_INSTANCE_WORK { inst.work = MAX_INSTANCE_WORK; let w = format!( - "more than {} KiB of text shown through elements: the rest show their text without the properties the passes on", + "more than {} KiB of text and text paths shown through elements: the rest show their text without the properties the passes on", MAX_INSTANCE_WORK >> 10 ); if !warnings.contains(&w) { @@ -688,6 +694,16 @@ struct Ctx { sizes: RefCell>, weights: RefCell>, elems: RefCell>>, + /// The path each `` read so far follows ([`Self::text_path`]); `None` where it has + /// no usable one. The styles of a `` instance share it, so each path is found and parsed + /// once however many `` elements show its text. + paths: Rc>>>>, +} + +#[cfg(test)] +thread_local! { + /// How many times [`Ctx::read_text_path`] has read a ``'s path on this thread. + static TEXT_PATH_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; } impl Ctx { @@ -700,6 +716,7 @@ impl Ctx { sizes: RefCell::default(), weights: RefCell::default(), elems: RefCell::default(), + paths: Rc::default(), }; let root = xml.root_element(); let dim = |a: &str| root.attribute(a).and_then(|v| cx.length(v, DEFAULT_FONT_SIZE, 0.0)).filter(|v| *v > 0.0); @@ -720,6 +737,7 @@ impl Ctx { sizes: RefCell::default(), weights: RefCell::default(), elems: RefCell::default(), + paths: self.paths.clone(), } } @@ -986,8 +1004,27 @@ impl Ctx { } } - /// The path a `` follows, in the text's user space. - fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option { + /// The path a `` follows, in the text's user space. It is read from the file once + /// ([`Self::paths`]). + fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option> { + if let Some(known) = self.paths.borrow().get(&tp.id()) { + return known.clone(); + } + let path = self.read_text_path(xml, tp).map(Rc::new); + self.paths.borrow_mut().insert(tp.id(), path.clone()); + path + } + + /// How many elements the path of `` `tp` has, when a text built so far follows it + /// ([`Self::text_path`]); 0 for any other node. + fn path_elements(&self, tp: XNode) -> usize { + self.paths.borrow().get(&tp.id()).and_then(|p| p.as_deref()).map_or(0, |p| p.elements().len()) + } + + /// Find and parse the path `` `tp` follows. + fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option { + #[cfg(test)] + TEXT_PATH_READS.set(TEXT_PATH_READS.get() + 1); let mut bp = match tp.attribute("path") { Some(d) => BezPath::from_svg(d).ok()?, None => { @@ -1625,3 +1662,6 @@ fn assemble(cells: &[Cell], breaks: &[Break]) -> (Runs, Vec) { } (out, bytes) } + +#[cfg(test)] +mod tests; diff --git a/crates/svg/src/import/text/tests.rs b/crates/svg/src/import/text/tests.rs new file mode 100644 index 0000000000..1508997dcc --- /dev/null +++ b/crates/svg/src/import/text/tests.rs @@ -0,0 +1,24 @@ +use super::*; + +/// The texts [`prepare`] builds from `body` and how many times it read a ``'s path. +fn prepared(body: &str) -> (usize, u64, Vec) { + let svg = format!(r#"{body}"#); + let xml = roxmltree::Document::parse(&svg).unwrap(); + let mut warnings = vec![]; + let before = TEXT_PATH_READS.get(); + let (_, slots) = prepare(&svg, &xml, None, 96.0, &mut warnings); + (slots.texts.len(), TEXT_PATH_READS.get() - before, warnings) +} + +/// Every `` of a text on a path follows the path the text's own build read. +#[test] +fn uses_of_a_text_on_a_path_read_the_path_once() { + let uses: String = (101..=150).map(|i| format!(r##""##)).collect(); + let (texts, reads, warnings) = + prepared(&format!(r##"Path{uses}"##)); + assert_eq!(texts, 51, "the text and one per : {warnings:?}"); + assert_eq!(reads, 1); + // A path with no usable data is not read again either. + let (texts, reads, _) = prepared(&format!(r##"Path{uses}"##)); + assert_eq!((texts, reads), (51, 1)); +} diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 87fe6cf340..b25dc23678 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -239,7 +239,28 @@ fn uses_of_a_long_text_stop_taking_their_properties_past_the_limit() { assert_eq!(sizes.len(), 4, "{sizes:?}"); assert!(close(sizes[0], 10.0, 1e-9) && close(sizes[1], 20.0, 1e-9), "{sizes:?}"); assert!(close(sizes[2], 12.0, 1e-9) && close(sizes[3], 12.0, 1e-9), "{sizes:?}"); - assert_eq!(w.iter().filter(|w| w.contains("KiB of text shown through elements")).count(), 1, "{w:?}"); + assert_eq!(w.iter().filter(|w| w.contains("KiB of text and text paths shown through elements")).count(), 1, "{w:?}"); +} + +/// The path a text follows counts toward that limit: each `` of the text lays it out along +/// the whole path again. +#[test] +fn uses_of_a_text_on_a_long_path_stop_taking_their_properties_past_the_limit() { + let d = format!("M0 0{}", "h1".repeat(350_000)); + let uses: String = (1..=4).map(|i| format!(r##""##, i * 10)).collect(); + let body = format!(r##"HI{uses}"##); + let (d, w) = import_with_report(&svg(&body)).unwrap(); + let sizes: Vec = texts(&d).iter().map(|t| t.first_style().size).collect(); + assert_eq!(sizes.len(), 4, "{sizes:?}"); + assert!(close(sizes[0], 10.0, 1e-9) && close(sizes[1], 20.0, 1e-9), "{sizes:?}"); + assert!(close(sizes[2], 12.0, 1e-9) && close(sizes[3], 12.0, 1e-9), "{sizes:?}"); + assert_eq!(w.iter().filter(|w| w.contains("KiB of text and text paths shown through elements")).count(), 1, "{w:?}"); + // The same text on a short path is within the limit however many `` elements show it. + let uses: String = (1..=300).map(|i| format!(r##""##)).collect(); + let body = format!(r##"HI{uses}"##); + let (d, w) = import_with_report(&svg(&body)).unwrap(); + assert!(texts(&d).iter().enumerate().all(|(i, t)| close(t.first_style().size, (i + 1) as f64, 1e-9)), "{w:?}"); + assert!(!w.iter().any(|w| w.contains("shown through elements")), "{w:?}"); } /// Many `` elements of one text that inherit the same properties each get it. From 971c8784027b42a36f372d95fe2f2afdb304c808 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:44:29 -0800 Subject: [PATCH 49/79] Export for Screens: an export into a preset's folder runs in the background With Background Export on, an Export for Screens call that gives only an `exportPreset` whose preset keeps a folder ran at once on the UI thread, because the background route looked at the call's own `folder` only. It now takes the preset's folder as well and writes in the background with the progress dialog and Cancel, as an export into a folder it names. When that folder is outside an MCP client's read roots, the prepared export carries the folder's shown name, so the reply written later lists the files relative to the folder with no ZIP `path`, a refused write names the folder by its name, and the status bar does the same, as for an export made at once. The status of a preset-only export made at once named an empty folder ("Exported 1 file to "); it now names the preset's folder. - `Session::screen_export_folder` returns the folder an export writes into and its shown name when the reply leaves it out. - `ScreenExport::write` applies the read-root rule decided when the export was prepared. - docs/mcp.md and docs/target-app-parity.md describe the change. --- crates/engine/src/cmd/fileio/screens.rs | 57 ++++++++++++-- crates/engine/src/cmd/fileio/tests_screens.rs | 75 +++++++++++++++++++ crates/ui-egui/src/dialogs/tests_screens.rs | 26 +++++++ crates/ui-egui/src/io/export.rs | 42 +++++++---- docs/mcp.md | 4 +- docs/target-app-parity.md | 1 + 6 files changed, 182 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index b48aef68c3..ca28407356 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -653,6 +653,11 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { None => e, })?; remember(s, &p); + // A prepared export is written later, maybe on another thread: it names the folder as this + // request may see it. + if let (Some(folder), Some(Some(export))) = (hidden, s.screens_hand_off.as_mut()) { + export.hidden = Some((folder.to_string(), super::access::shown_path(folder).into_owned())); + } if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) { leave_out_unreadable_folder(&mut r, folder); } @@ -663,10 +668,14 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { /// give: `folder` as the path the error is about names it by its own name /// ([`super::access::shown_path`]), and the rest of the message stays. fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError { - let name = super::access::shown_path(folder); + leave_out_folder_named(e, folder, &super::access::shown_path(folder)) +} + +/// [`leave_out_folder_from_error`] with `folder` named `name`. +fn leave_out_folder_named(e: EngineError, folder: &str, name: &str) -> EngineError { match e { - EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, &name)), - EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, &name) }, + EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, name)), + EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, name) }, e => e, } } @@ -699,9 +708,14 @@ fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String /// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`), /// as the reply of an export to a ZIP names them. fn leave_out_unreadable_folder(r: &mut Value, folder: &str) { - if super::access::shown(folder) { - return; + if !super::access::shown(folder) { + leave_out_folder(r, folder); } +} + +/// The reply `r` of an export into `folder` with the ZIP's `path` left out and `files` named +/// relative to `folder` ([`leave_out_unreadable_folder`]). +fn leave_out_folder(r: &mut Value, folder: &str) { let Some(o) = r.as_object_mut() else { return }; o.remove("path"); let inside = format!("{folder}/"); @@ -1074,7 +1088,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { // Include Artboard Fill off: the encoders leave the background colors out. let fill = if o.include_artboard_fill == Some(false) { json!({ "includeArtboardFill": false }) } else { json!({}) }; let rows = rows.into_iter().map(|r| Row { options: merge(with_black_output(s, &r.options), fill.clone()), ..r }).collect(); - let export = ScreenExport { cmd: C, o, rows, naming, items }; + let export = ScreenExport { cmd: C, o, rows, naming, items, hidden: None }; match &mut s.screens_hand_off { Some(slot) => { *slot = Some(export); @@ -1095,9 +1109,27 @@ pub struct ScreenExport { rows: Vec, naming: Naming, items: Vec, + /// The export folder and the name the request that prepared the export may see it by, when the + /// folder came from the `exportPreset` and is outside that request's read roots: the reply and + /// the errors of [`ScreenExport::write`] name it as [`export_for_screens`] does. + hidden: Option<(String, String)>, } impl Session { + /// The folder `document.exportForScreens` (`id`) with `params` writes into, the client's + /// `folder` or else the one its `exportPreset` keeps, with the name the request being served may + /// see it by when the reply leaves it out: a preset's folder outside the read roots, named by + /// its own name ([`super::access::shown_path`]). `assets.export` takes no preset, so for it the + /// folder is the client's. → `None` without a folder (the files come back in the reply) or for + /// an unknown preset, which the export itself refuses. + pub fn screen_export_folder(&self, id: &str, params: &Value) -> Option<(String, Option)> { + let given = params.get("folder").is_some(); + let p = if given || id == ASSETS { params.clone() } else { with_preset(self, params).ok()? }; + let folder = str_param(&p, "folder").filter(|f| !f.is_empty())?.to_string(); + let hidden = (!given && !super::access::shown(&folder)).then(|| super::access::shown_path(&folder).into_owned()); + Some((folder, hidden)) + } + /// Run `assets.export` when `id` names it, else `document.exportForScreens`, with `params` as /// a command (checked and journaled as any other; Export for Screens remembers its settings) /// that encodes and writes nothing → the export it prepared, which [`ScreenExport::write`] @@ -1123,10 +1155,19 @@ impl ScreenExport { /// return → the reply of `document.exportForScreens`. It reports one progress step per file /// ([`vectorcraft_doc::cancel::progress`]), and a cancel stops it before it encodes or writes /// another file. - pub fn write(self, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { + pub fn write(mut self, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { let cmd = self.cmd; + let hidden = self.hidden.take(); // A refusal names `assets.export` when that command prepared the export, as its reply does. - encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) }) + let r = encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) }); + match hidden { + Some((folder, name)) => { + let mut r = r.map_err(|e| leave_out_folder_named(e, &folder, &name))?; + leave_out_folder(&mut r, &folder); + Ok(r) + } + None => r, + } } } diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 86ee164998..880bcdae87 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -735,6 +735,81 @@ fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_ assert!(e.contains(&format!("root {root}: {name}")), "{e}"); } +/// A prepared export into the folder an export preset keeps, prepared for a client whose read roots +/// don't hold that folder and written later outside that client's scope (as on the background +/// thread): its reply leaves out the ZIP's path and names the files relative to the folder, and a +/// refused write names the folder by its name. A folder inside the read roots, and one the client +/// gives, stay whole. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_prepared_export_into_a_presets_folder_outside_the_read_root_leaves_it_out() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("prepared-roots"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png", "folder": "1x"}], "subfolders": true, "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let write_only = AutomationRoots::new(&[inside.as_path()], &[out.as_path()]).unwrap(); + let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[out.as_path()]).unwrap(); + let prepare = |s: &mut Session, roots: &Option>, p: Value| { + confine(roots.as_ref(), || s.prepare_screen_export("document.exportForScreens", &p)).unwrap() + }; + + let (r, files) = write_prepared(prepare(&mut s, &write_only, json!({"exportPreset": "Kept"}))); + assert_eq!(r["files"], json!(["1x/Artboard-1.png"]), "{r}"); + assert!(files.contains_key(&format!("{folder}/1x/Artboard-1.png")), "the files are written into the folder"); + let (r, _) = write_prepared(prepare(&mut s, &write_only, json!({"exportPreset": "Kept", "zip": true}))); + assert_eq!((r.get("path"), &r["files"]), (None, &json!(["1x/Artboard-1.png"])), "{r}"); + assert!(!r.to_string().contains(&folder), "{r}"); + // A refused write names the folder by its name. + let refused = prepare(&mut s, &write_only, json!({"exportPreset": "Kept"})); + let e = refused.write(&mut |path, _| Err(crate::EngineError::Other(format!("{path}: access denied")))).unwrap_err().to_string(); + assert!(e.contains("kept-out/1x/Artboard-1.png: access denied") && !e.contains(&folder), "{e}"); + // Inside the read roots, or given by the client: whole. + let (r, _) = write_prepared(prepare(&mut s, &both, json!({"exportPreset": "Kept"}))); + assert_eq!(r["files"], json!([format!("{folder}/1x/Artboard-1.png")]), "{r}"); + let (r, _) = write_prepared(prepare(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder, "zip": true}))); + assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}"); + let given = prepare(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder})); + let e = given.write(&mut |path, _| Err(crate::EngineError::Other(format!("{path}: access denied")))).unwrap_err().to_string(); + assert!(e.contains(&folder), "{e}"); +} + +/// [`Session::screen_export_folder`]: the client's folder, else the one the export preset keeps, +/// with its own name when the request's read roots don't hold it; none without a folder, for an +/// unknown preset or for `assets.export`, which takes no preset. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn the_export_folder_is_the_clients_else_the_presets() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("export-folder"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png"}], "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let ask = |id: &str, p: Value| confine(confined.as_ref(), || s.screen_export_folder(id, &p)); + + let screens = "document.exportForScreens"; + assert_eq!(ask(screens, json!({"exportPreset": "Kept"})), Some((folder.clone(), Some("kept-out".to_string())))); + assert_eq!(ask(screens, json!({"exportPreset": "Kept", "folder": "/x/y"})), Some(("/x/y".to_string(), None))); + assert_eq!(ask(screens, json!({"exportPreset": "Kept", "folder": ""})), None); + assert_eq!(ask(screens, json!({"exportPreset": "Nope"})), None); + assert_eq!(ask(screens, json!({})), None); + assert_eq!(ask("assets.export", json!({"exportPreset": "Kept"})), None); + assert_eq!(s.screen_export_folder(screens, &json!({"exportPreset": "Kept"})), Some((folder, None)), "unconfined: whole"); +} + /// The export settings the document keeps and the saved presets, read by a client whose read roots /// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and /// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest. diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 97e2b16ee7..7ae09dbdf6 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -341,6 +341,32 @@ fn edits_after_ok_do_not_reach_the_files() { let _ = std::fs::remove_dir_all(&dir); } +/// An export into the folder its export preset keeps, with no folder of its own, is written in the +/// background as one into a folder it names, and the status bar names that folder. +#[test] +fn an_export_into_a_presets_folder_writes_in_the_background() { + let gate = Gate::default(); + let (mut app, _, files) = app_with_worker(1, &gate); + let dir = temp_folder("preset-folder"); + let folder = dir.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "svg"}], "folder": folder}); + app.run("document.exportPresets.save", json!({"name": "Kept", "settings": settings})).unwrap(); + let p = json!({"exportPreset": "Kept"}); + background_export(&mut app, false); + let want = on_disk(&app.run("file.exportForScreens", p.clone()).unwrap()); + assert_eq!(want.len(), 1); + let status = app.ui.status.clone(); + assert_eq!(status, format!("Exported 1 file to {folder}")); + std::fs::remove_dir_all(&dir).unwrap(); + background_export(&mut app, true); + assert_eq!(app.run("file.exportForScreens", p).unwrap()["background"], true); + assert!(written(&files).is_empty(), "nothing written yet"); + gate.open(); + background::wait_all(&mut app); + assert_eq!((files.lock().unwrap().clone(), app.ui.status.clone()), (want, status)); + let _ = std::fs::remove_dir_all(&dir); +} + /// Cancel stops the export before its next file; the file being written when it came stays. #[test] fn cancel_stops_the_export_before_its_next_file() { diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 03e0196267..85ef8ffa0c 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -265,13 +265,19 @@ pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result Result { let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); - let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); + // The folder written into, and its name when the reply leaves it out. + let (folder, hidden) = if id == "assets.export3D" { + (params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(), None) + } else { + app.session.screen_export_folder(id, ¶ms).unwrap_or_default() + }; // Without a folder the files come back in the reply, which the caller waits for. let in_background = app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty() && id != "assets.export3D"; @@ -279,7 +285,7 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> // Made at once, after the exports and saves started before it. background::wait_all(app); let r = app.run(id, params)?; - return report_exported(app, r, &folder, open_location); + return report_exported(app, r, &folder, hidden.as_deref(), open_location); } let export = app.session.prepare_screen_export(id, ¶ms).map_err(|e| e.to_string()).inspect_err(|e| app.ui.status = e.clone())?; let label = format!("Exporting {}", fileio::file_name(&folder)); @@ -299,14 +305,15 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> return Ok(json!({ "cancelled": true })); } let r = r.inspect_err(|e| report_write_error(app, true, &folder, e))?; - report_exported(app, r, &folder, open_location) + report_exported(app, r, &folder, hidden.as_deref(), open_location) }) } /// Report the files `r` (the reply of `document.exportForScreens`) in the status bar: the count -/// written into `folder`, else the ZIP or the files downloaded (the web); with `open_location`, -/// the file manager shows the first file written. -fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_location: bool) -> Result { +/// written into `folder` (named `hidden` when the reply leaves it out), else the ZIP or the files +/// downloaded (the web); with `open_location`, the file manager shows the first file written, or +/// the folder for a ZIP whose path the reply leaves out. +fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, hidden: Option<&str>, open_location: bool) -> Result { let count = r["files"].as_array().map_or(0, Vec::len); let warned = r["warnings"].as_array().is_some_and(|w| !w.is_empty()); // Files that came back as bytes: (name, base64). @@ -317,15 +324,22 @@ fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_locati } }; if returned.is_empty() { - let first = r["path"].as_str().or_else(|| r["files"][0].as_str()); - let mut status = crate::i18n::msg_n(count as u64, "Exported {n} file to {folder}", "Exported {n} files to {folder}", &[("folder", folder)]); + let shown = hidden.unwrap_or(folder); + let first = match r["path"].as_str().or_else(|| r["files"][0].as_str()) { + // The reply names the files relative to a folder it leaves out, and a ZIP's files inside it. + Some(_) if hidden.is_some() && r.get("bytes").is_some() => Some(folder.to_string()), + Some(name) if hidden.is_some() => Some(format!("{folder}/{name}")), + first => first.map(str::to_string), + }; + let mut status = crate::i18n::msg_n(count as u64, "Exported {n} file to {folder}", "Exported {n} files to {folder}", &[("folder", shown)]); if warned { - status = saved_status(r["path"].as_str().unwrap_or(folder), &r); + status = saved_status(r["path"].as_str().unwrap_or(shown), &r); } if open_location && let Some(first) = first - && let Err(e) = reveal_path(app, first) + && let Err(e) = reveal_path(app, &first) { + let e = if hidden.is_some() { e.replace(folder, shown) } else { e }; status = format!("{status} ({e})"); } show_with_notes(app, status, notes_of(&r)); diff --git a/docs/mcp.md b/docs/mcp.md index baa2631f7b..39ff2a18ad 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -129,7 +129,9 @@ the suite) keep the agent to the folders you give it: written beside it by their file names. A save without a path known (`file.saveAs` and `file.saveCopy` without one, a document never saved) leaves out the suggested `folder`. `document.exportForScreens` into the `folder` of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder, and an error - for that export names the folder by its name alone. + for that export names the folder by its name alone. With Background Export on, an export into the `folder` of + its `exportPreset` runs in the background as one into a `folder` the agent gives, and its replies, errors and + status bar text name that folder the same way. `document.exportSettings`, `document.exportPresets.list` and `document.exportPresets.export` (as data and as a file) leave out a kept `folder` outside the read roots. `file.package` names the document's file and the linked files outside the read roots by their file names in `warnings`, in diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f28078ec33..ed6f70af82 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1558,6 +1558,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. - On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before. + - With Enable Background Export on, an Export for Screens with no folder of its own also writes in the background into the folder its `exportPreset` keeps, with the same progress dialog and Cancel, and the status bar names that folder. When that folder is outside an MCP client's read roots, the reply, its errors and the status bar name it by its own name, as for an export made at once (`dialogs::tests_screens::an_export_into_a_presets_folder_writes_in_the_background`, `fileio::tests_screens::a_prepared_export_into_a_presets_folder_outside_the_read_root_leaves_it_out`). Behavior reference: the help page, https://helpx.adobe.com/illustrator/using/exporting-artwork.html (exports run in the background by default). - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. - The Links panel's Relink… opens a Relink dialog whose Replace menu picks which links take the new file: Selected Instances (the selected images, as before), All Instances (every image linked to the same file), Missing Links from This Folder (then every other missing link whose file name is in the new file's folder) and Each Link Separately (a file for each selected link in turn). Each relinks as one undo step (`links.relink {scope}`, `ui.relinkDialog`). Relink to Folder… stays as it is. - Linked files inside symbols, graph designs, patterns and opacity masks are saved with their paths relative to the document, as linked files in the layers are, so they are found after the document and its files move to another folder together; on open each of those links takes the path where its file was found. Update Links, and opening with Update Links: Automatically, read a modified file again for that art too, and saving a document refreshes its placed copies inside symbols and patterns of the other open documents. Evidence: `links_outside_the_layers_find_their_files_after_the_folder_moves`, `update_links_reads_files_again_inside_symbols_patterns_and_masks`, `saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns`. From 8187f7ae00cb2731e8d9ced6918b3a8c04f3b0bc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:45:26 -0800 Subject: [PATCH 50/79] Images: a CMYK JPEG turned by its EXIF orientation keeps its inks A four-component JPEG whose EXIF orientation turns or flips it went through the image decoder, which reads CMYK JPEGs as RGB, and was stored as an RGB PNG without its inks. It is now stored the way a turned CMYK TIFF is: its inks, 8 bits each, turned and written as a CMYK TIFF with the turned resolution. - vectorcraft_doc::cmyk::turned_cmyk turns the inks of a CMYK JPEG or TIFF, next to stored_cmyk_tiff; tiff_inks is private again. - raster_image swaps the resolution axes inline, as its HEIF branch does. - The links comment and the docs/target-app-parity.md line on EXIF orientation now describe the turned CMYK TIFF. --- crates/doc/src/cmyk.rs | 25 ++++++++++++---- crates/engine/src/cmd/fileio/load.rs | 27 +++++------------- crates/engine/src/cmd/fileio/tests.rs | 41 +++++++++++++++++++++++++++ crates/engine/src/cmd/links.rs | 2 +- docs/target-app-parity.md | 2 +- 5 files changed, 70 insertions(+), 27 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index a3a6f7b016..f94f0ec4a8 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -306,7 +306,7 @@ fn jpeg_inks(bytes: &[u8]) -> Option { /// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane /// per ink. -pub fn tiff_inks(bytes: &[u8]) -> Option { +fn tiff_inks(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { @@ -316,10 +316,10 @@ pub fn tiff_inks(bytes: &[u8]) -> Option { Inks::new(w, h, read_samples(&mut d, n, 4)?) } -/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per -/// ink, which the image decoder doesn't read, their ink amounts written again by -/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK -/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read. +/// CMYK TIFF `bytes` that no orientation turns ([`turned_cmyk`] stores the others) as Open and +/// Place store them: as they are, or, when they store one plane per ink, which the image decoder +/// doesn't read, their ink amounts written again by [`ImageBlob::cmyk_tiff_at`] with the resolution +/// `ppi`. `None` for other images and for CMYK TIFFs whose inks [`ImageBlob::cmyk`] doesn't read. pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option> { let inks = tiff_inks(bytes)?; let (_, mut d) = tiff(bytes)?; @@ -329,6 +329,21 @@ pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes)) } +/// CMYK JPEG or TIFF `bytes` that `orientation` turns or flips, as Open and Place store them: their +/// ink amounts ([`ImageBlob::cmyk`], 8 bits each) turned and written again by +/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi` of the turned pixels, with the turned +/// width and height. `None` for other images and for CMYK images whose inks [`ImageBlob::cmyk`] +/// doesn't read. +pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option<(Vec, (u32, u32))> { + let inks = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }?; + // Four 8-bit inks to a pixel, turned as four 8-bit channels are. + let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?); + px.apply_orientation(orientation); + let inks = Inks::new(px.width(), px.height(), px.into_bytes())?; + let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; + Some((Arc::unwrap_or_clone(tiff.bytes), (inks.width, inks.height))) +} + /// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at /// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink /// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 7df402724a..7cb5d370bb 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -815,8 +815,8 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are /// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in. /// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is -/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK TIFF -/// keeps its inks, 8 bits each ([`turned_cmyk_tiff`]). +/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK JPEG +/// or TIFF keeps its inks, 8 bits each, as a CMYK TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]). pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -858,11 +858,12 @@ pub fn raster_image(bytes: &[u8]) -> Result { let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten(); let turned = orientation.is_some(); // The resolution describes the stored pixels; a quarter turn swaps its axes. - let shown_ppi = jxlread::oriented_resolution(ppi, orientation.map_or(1, |o| u32::from(o.to_exif()))); - if let Some(orientation) = orientation.filter(|_| f.id == "tiff") - && let Some(stored) = turned_cmyk_tiff(bytes, orientation, shown_ppi) + let quarter = orientation.is_some_and(|o| o.to_exif() >= 5); + let shown_ppi = ppi.map(|(x, y)| if quarter { (y, x) } else { (x, y) }); + if let Some(orientation) = orientation.filter(|_| matches!(f.id, "jpg" | "tiff")) + && let Some((tiff, size)) = vectorcraft_doc::cmyk::turned_cmyk(bytes, orientation, shown_ppi) { - return stored; + return stored(tiff, "image/tiff", size, shown_ppi); } let kept = match f.id { _ if turned => None, @@ -914,20 +915,6 @@ pub(crate) fn exif_orientation(bytes: &[u8]) -> Option) -> Option> { - let inks = vectorcraft_doc::cmyk::tiff_inks(bytes)?; - // Four 8-bit inks to a pixel, turned as four 8-bit channels are. - let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?); - px.apply_orientation(orientation); - let inks = vectorcraft_doc::cmyk::Inks::new(px.width(), px.height(), px.into_bytes())?; - let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; - Some(stored(std::sync::Arc::unwrap_or_clone(tiff.bytes), "image/tiff", (inks.width, inks.height), ppi)) -} - /// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its /// colors are in. pub(super) fn as_png(img: image::RgbaImage, icc: Option>, ppi: Option<(f64, f64)>) -> Result { diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 5f508bd6ec..5ea57858cc 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -1078,6 +1078,47 @@ fn cmyk_tiffs_turned_by_their_orientation_keep_their_inks() { } } +/// A CMYK JPEG that its EXIF orientation turns or flips keeps its inks, turned, stored as a CMYK +/// TIFF; a quarter turn swaps the resolution's axes. Orientation 1 keeps the JPEG as it is, and +/// the stored image, read again, is not turned a second time. +#[test] +fn cmyk_jpegs_turned_by_their_orientation_keep_their_inks() { + use vectorcraft_render::encode::jpeg::{self, ColorModel, JpegOptions}; + // 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) full cyan, the rest 30% black. + let inks: Vec = + (0..32u32).flat_map(|y| (0..48u32).flat_map(move |x| if x < 16 && y < 16 { [255, 0, 0, 0] } else { [0, 0, 0, 77] })).collect(); + let o = JpegOptions { color_model: ColorModel::Cmyk, embed_icc: false, ..Default::default() }; + let jpg = jpeg::encode(&inks, 48, 32, 100, None, &o).unwrap(); + for orientation in 1..=8u16 { + let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat(); + let mut file = jpg[..2].to_vec(); + file.extend([0xff, 0xe1]); + file.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + file.extend(&exif); + file.extend(&jpg[2..]); + let what = format!("orientation {orientation}"); + let quarter = orientation >= 5; + let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) }; + let mime = if orientation == 1 { "image/jpeg" } else { "image/tiff" }; + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), mime), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost")); + assert_eq!((inks.width, inks.height), size, "{what}: the inks' size"); + let (w, h) = size; + let at = |x: u32, y: u32| inks.data[usize::try_from((y * w + x) * 4).unwrap()..][..4].to_vec(); + let cyan = |p: &[u8]| p[0] > 200 && p[3] < 40; + let corners = [(false, false), (true, false), (true, true), (false, true)]; + let cyans: Vec<_> = + corners.into_iter().filter(|&(right, bottom)| cyan(&at(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 }))).collect(); + assert_eq!(cyans, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner"); + let black = at(w / 2, h / 2); + assert!(black[0] < 40 && (60..=95).contains(&black[3]), "{what}: the other inks, {black:?}"); + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again"); + } +} + /// A PNG (its `eXIf` chunk and `pHYs` resolution), a WebP (its `EXIF` chunk) and an RGB TIFF read /// by the image decoder, turned a quarter by their orientation, swap their resolution's axes; a /// half turn keeps them. diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a134eb1c55..41417a4433 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -436,7 +436,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a continue; }; let preview = d.images.get(&key).map(|b| b.bytes.clone()); - // A file read as it is keeps its own type; one turned upright (EXIF orientation) is the PNG it became. + // A file read as it is keeps its own type; one turned upright by its orientation is the PNG, or CMYK TIFF, it became. let mime = match fileio::format_for_name(g.link.name()) { Some(f) if img.blob.bytes.as_slice() == bytes.as_slice() => f.mime.to_string(), _ => img.blob.mime.clone(), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3cc8df2a9e..49037bd59f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1553,7 +1553,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop, File › Place reads and decodes files off the UI thread, one at a time, and places each once it is read, with "Placing" in the status bar and the busy spinner until then: the Place dialog's OK, a file dropped on the canvas, a click or drag of the place cursor, and the OK of the PDF, DXF and text import options. The files and thumbnails the place cursor is loaded with are read the same way, and the Place dialog's lines about its files are read on a thread of their own. Files are placed in the order they were started; a file is not placed while a dialog is open or a drag is under way, and a file whose document was closed meanwhile is not placed. `file.place` and `file.place.queue` over MCP and scripts reply as before. - On the desktop, Paste from a menu or shortcut captures another app's bitmap or reads a copied image file on the UI thread, then decodes the image on the file-reading worker. The status bar shows "Pasting" until the image is pasted. Windows device-independent bitmaps and Linux clipboard images are encoded as PNG on that worker. A Copy or Cut made while the image is read keeps its internal clipboard. `edit.paste*` over MCP and scripts reads the bitmap at once. - The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`). - - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). + - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, 8 bits each, as a CMYK TIFF. - File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place : the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`). - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. From ebad5d75c02db784e6e45136061a8ce527818177 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:21:43 +0430 Subject: [PATCH 51/79] Use the unencoded fixture glyph in ligature range regression --- crates/text/src/tests/ligature_carets.rs | 3 ++- docs/type-parity.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index ce0abc40c1..d7d8334400 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -685,7 +685,8 @@ fn a_ligature_feature_still_applies_only_to_its_character_range() { crate::edit::style_range(&mut text.runs, 0, 1, |st| st.features = vec!["-liga".into()]); let after = layout(&db, &text); let glyphs = after.glyphs.iter().map(|g| (g.gid, g.byte, g.len)).collect::>(); - assert_eq!(glyphs, [(face.glyph_for('f'), 0, 1), (face.glyph_for('i'), 1, 1), (face.glyph_for(LIGATURE_CARET_GLYPH), 2, 2)]); + let ligature = u32::from(source_sans_gid(LIGATURE_CARET_GLYPH).expect("the fixture's unencoded ligature glyph")); + assert_eq!(glyphs, [(face.glyph_for('f'), 0, 1), (face.glyph_for('i'), 1, 1), (ligature, 2, 2)]); } /// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets, so diff --git a/docs/type-parity.md b/docs/type-parity.md index 058ec947fa..c6a2d61a74 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -84,7 +84,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| -| 2026-11-12 | minor | Glyphs review (#6084): validate contextual glyph choices against their supplying face, including fallback and overflow; keep feature-only boundaries inside ligatures and Arabic mark clusters while retaining drawing-attribute splits; preserve insertion failures instead of reporting normalization success. Deterministic engine, shaping and panel regression sources added; runtime proof pending. Scores and estimates unchanged. | +| 2026-11-12 | minor | Glyphs review (#6084): validate contextual glyph choices against their supplying face, including fallback and overflow; keep feature-only boundaries inside ligatures and Arabic mark clusters while retaining drawing-attribute splits; preserve insertion failures instead of reporting normalization success. Deterministic engine, shaping and panel regression sources added; ligature range assertions use the fixture's unencoded glyph identity. Runtime proof pending. Scores and estimates unchanged. | | 2026-11-12 | minor | Glyphs review (#6084): Entire Font resolves the selected font's version and variable axes; the default cell remains available to reset an unlisted choice with no listed alternates. Character insertion normalizes only Arabic presentation forms, preserving ordinary characters whose glyphs share Arabic substitution provenance. Add deterministic panel apply/reset/Undo regressions without OS-font dependencies. Scores and estimates unchanged. | | 2026-11-09 | minor | Glyphs review (#6084): the engine accepts a positional alternate as a glyph choice when no supported feature draws it, not only an isolated character's alternates; both panel views exercise apply and Undo. OpenType style options preserve only a valid last on-value when enabling a stylistic set. Scores and estimates unchanged. | | 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, applies the ones that are the selected letter's alternates, or that no feature reaches and the font's names do not give to another letter, and dims, with the reason, those that are not. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. | From 43b6a793b454ae17c3eba94eedefa839b452388a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:54:24 -0800 Subject: [PATCH 52/79] Color: the screen color sampler's bus tests wait for the answer without a time limit The tests call the sampler's pick directly instead of giving it 30 seconds on another thread. --- .../src/linux_screen_color/portal.rs | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/apps/vectorcraft/src/linux_screen_color/portal.rs b/apps/vectorcraft/src/linux_screen_color/portal.rs index 4144a373cb..b7518e1ce3 100644 --- a/apps/vectorcraft/src/linux_screen_color/portal.rs +++ b/apps/vectorcraft/src/linux_screen_color/portal.rs @@ -220,17 +220,6 @@ mod tests { connect(zbus::blocking::connection::Builder::address(address)).unwrap() } - /// [`pick`] on `conn`, given 30 seconds to answer: an answer that is never awaited fails the - /// test instead of leaving it waiting. - fn pick_within(conn: &Connection, parent: &str) -> Result, Failure> { - let (tx, rx) = std::sync::mpsc::channel(); - let (conn, parent) = (conn.clone(), parent.to_string()); - std::thread::spawn(move || { - let _ = tx.send(pick(&conn, &parent)); - }); - rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the portal's answer was not awaited within 30 seconds") - } - /// The portal's name, owned on the private bus at `address` before any call, so the bus never /// starts an installed portal in its place. fn portal(address: &str) -> Connection { @@ -295,10 +284,10 @@ mod tests { calls }); let client = client(address); - assert_eq!(pick_within(&client, "x11:12"), Ok(Some([0.25, 0.5, 0.75]))); - assert_eq!(pick_within(&client, "wayland:example"), Ok(None)); - assert_eq!(pick_within(&client, ""), Ok(Some([0.25, 0.5, 0.75]))); - assert!(matches!(pick_within(&client, ""), Err(Failure::NoAnswer(_)))); + assert_eq!(pick(&client, "x11:12"), Ok(Some([0.25, 0.5, 0.75]))); + assert_eq!(pick(&client, "wayland:example"), Ok(None)); + assert_eq!(pick(&client, ""), Ok(Some([0.25, 0.5, 0.75]))); + assert!(matches!(pick(&client, ""), Err(Failure::NoAnswer(_)))); let calls = worker.join().unwrap(); assert_eq!(calls.iter().map(|(parent, _)| parent.as_str()).collect::>(), ["x11:12", "wayland:example", "", ""]); } @@ -323,7 +312,7 @@ mod tests { }); let client = client(address); assert_eq!(client.method_timeout(), Some(CALL_TIMEOUT)); - let failure = pick_within(&client, ""); + let failure = pick(&client, ""); assert!(matches!(failure, Err(Failure::Unavailable(_))), "{failure:?}"); worker.join().unwrap(); } From c6579f147f9da50cd4ec8de1f9e39acd93449112 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:59:30 -0800 Subject: [PATCH 53/79] Cutting Tools: Weed Box and Lines refuses a Spacing below 0 The dialog sent the Count instead of a Spacing that was not above 0, so a Spacing below 0 ran with the Count. Now only a Spacing of 0, or none, leaves the Count in use, and any other Spacing goes to the command, which refuses one below 0. --- crates/ui-egui/src/addons/cutting/tests.rs | 24 ++++++++++++++++++++++ crates/ui-egui/src/dialogs/command.rs | 13 ++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/addons/cutting/tests.rs b/crates/ui-egui/src/addons/cutting/tests.rs index 39adb0539f..2e69eab904 100644 --- a/crates/ui-egui/src/addons/cutting/tests.rs +++ b/crates/ui-egui/src/addons/cutting/tests.rs @@ -118,4 +118,28 @@ fn weed_box_and_lines_previews_cancels_and_runs_on_ok() { crate::dialogs::set_field(d, "spacing".into(), json!(20)); crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(count(&app), nodes + 8, "a group, the box and six pieces of line"); + + // A Spacing below 0 is refused, not swapped for the Count. + app.run("edit.undo", json!({})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + let n = steps(&app); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "spacing".into(), json!(-5)); + let e = crate::dialogs::confirm(&mut app).unwrap_err().to_string(); + assert!(e.contains("`spacing` must be more than 0"), "{e}"); + assert_eq!(steps(&app), n, "no undo step"); + + // A Spacing with no value, or of 0 written with its unit, leaves the Count in use: a Count of 0 + // draws the box alone. + crate::dialogs::cancel(&mut app); + for spacing in [serde_json::Value::Null, json!("0 pt")] { + app.run("select.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::set_field(d, "spacing".into(), spacing.clone()); + crate::dialogs::set_field(d, "count".into(), json!(0)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(count(&app), nodes + 2, "{spacing}: a group and the box"); + app.run("edit.undo", json!({})).unwrap(); + } } diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 374868e27f..2e277caea8 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -87,13 +87,18 @@ fn call(d: &Dialog) -> (String, Value) { { o.remove("rowGutter"); } - // Weed Box and Lines: a Spacing above 0 divides the box instead of the Count, and only the one - // in use is sent. + // Weed Box and Lines: a Spacing of 0, or none, leaves the Count in use; any other Spacing is + // sent instead of the Count, so the command refuses one below 0. if d.str("__command") == crate::addons::cutting::WEED && let Some(o) = params.as_object_mut() { - let by_spacing = o.get("spacing").and_then(Value::as_f64).is_some_and(|s| s > 0.0); - o.remove(if by_spacing { "count" } else { "spacing" }); + let zero = |s: &Value| match s { + Value::Null => true, + Value::String(t) => vectorcraft_doc::Unit::Points.parse(t).or_else(|| t.trim().parse().ok()) == Some(0.0), + v => v.as_f64() == Some(0.0), + }; + let by_count = o.get("spacing").is_none_or(zero); + o.remove(if by_count { "spacing" } else { "count" }); } (d.str("__command"), params) } From 705503da6d6ce0c54d20713f19611728a8f362c2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:02:04 -0800 Subject: [PATCH 54/79] Objects on Path: plug-ins may call Select and Move Object on Path object.objectsOnPath.selectMember and object.objectsOnPath.moveMember change only the document and its selection, as the other Objects on Path commands do, so both join the commands a plug-in may call when its manifest lists them. --- crates/engine/src/cmd/plugin_call/reviewed.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 4c9abdb357..355179c4fc 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -307,7 +307,9 @@ pub(super) const ALLOWED: &[&str] = &[ "object.nudge", "object.objectsOnPath.attach", "object.objectsOnPath.expand", + "object.objectsOnPath.moveMember", "object.objectsOnPath.options", + "object.objectsOnPath.selectMember", "object.path.addAnchorPoints", "object.path.cleanUp", "object.path.divideObjectsBelow", From 2212739c5425e12430ef5632c3e14559557f0730 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:46:02 -0800 Subject: [PATCH 55/79] MCP: plug-in, recovery and script report replies leave out paths outside the read roots A client confined by --automation-read-root saw paths outside its read roots that the person or an earlier request had put in the session: - plugin.list: the plug-in folder last loaded and its failed files; plugin.list and plugin.info: a plug-in's source file; plugin.list, plugin.safeMode and plugin.setEnabled: the file of a plug-in turned off for the next launch. - file.recovery.list and file.recovery.restore: a document's original file; file.recovery.list and file.recovery.save: the recovery folder. - ui.inspect's scriptReport: the files a run wrote and the included file a failed run stopped in. A folder outside the read roots is now null, and a file outside them is named by its file name. plugin.setEnabled {file} takes that name. The restored documents keep their files, and unconfined clients and the app's own UI see the same replies as before. --- crates/engine/src/cmd/plugin.rs | 94 ++++++++++++++++++++--- crates/engine/src/cmd/recovery.rs | 39 ++++++++-- crates/engine/src/cmd/scripts.rs | 44 +++++++++++ crates/engine/src/script/mod.rs | 30 ++++++-- crates/engine/src/tests/plugins.rs | 86 +++++++++++++++++++++ crates/engine/src/tests/recovery.rs | 65 ++++++++++++++++ crates/engine/src/tests/script_console.rs | 28 +++++++ crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/tests/automation.rs | 44 +++++++++++ docs/mcp.md | 11 +++ docs/target-app-parity.md | 1 + 11 files changed, 418 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 2d16bbc683..dfa44a1436 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -62,13 +62,28 @@ fn err(e: vectorcraft_plugins::Error) -> EngineError { } } +/// Under automation read roots, a reply names a file or folder outside them by its name alone. +fn shown_file(path: &str) -> String { + super::fileio::shown_path(path).into_owned() +} + +/// A plug-in turned off for the next launch, or the one a launch stopped at, as a reply shows it: a +/// file outside the automation read roots is named by its file name. +fn shown_item(item: &safe_mode::Item) -> Value { + let mut v = item.to_json(); + if let Some(file) = item.file.as_deref() { + v["file"] = json!(shown_file(file)); + } + v +} + /// JSON description of an installed plug-in. pub fn describe(p: &Plugin) -> Value { let m = p.manifest(); let mut v = json!({ "id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description, "params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()), - "size": p.size(), "source": p.source(), + "size": p.size(), "source": p.source().map(shown_file), "permissions": permissions::names(&m.permissions()), "hash": p.hash(), "api": m.api, }); match m.kind { @@ -932,9 +947,46 @@ fn describe_installed(p: &Plugin) -> Value { v } +/// The folder load `report` as a reply shows it: under automation read roots, a folder outside them +/// is `null` and a failed file outside them is named by its file name, in the errors too. +fn shown_report(mut report: Value) -> Value { + let mut hidden: Vec<(String, String)> = vec![]; + if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) { + for row in failed { + let Some(file) = row.get("file").and_then(Value::as_str).map(str::to_string) else { continue }; + let shown = shown_file(&file); + if shown != file { + row["file"] = json!(shown); + hidden.push((file, shown)); + } + } + } + if let Some(folder) = report.get("folder").and_then(Value::as_str).map(str::to_string) { + let shown = shown_file(&folder); + if shown != folder { + report["folder"] = Value::Null; + hidden.push((folder, shown)); + } + } + // A failed file's path holds its folder's: the longer paths are put back first. + hidden.sort_by_key(|(path, _)| std::cmp::Reverse(path.len())); + let unnamed = |text: &str| hidden.iter().fold(text.to_string(), |t, (path, name)| t.replace(path.as_str(), name)); + if let Some(e) = report.get("error").and_then(Value::as_str).map(unnamed) { + report["error"] = json!(e); + } + if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) { + for row in failed { + if let Some(e) = row.get("error").and_then(Value::as_str).map(unnamed) { + row["error"] = json!(e); + } + } + } + report +} + fn list(_: &mut Session, _: &Value) -> Result { - let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); - let disabled: Vec = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect(); + let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| shown_report(r.clone())); + let disabled: Vec = safe_mode::disabled().iter().map(shown_item).collect(); Ok( json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), ) @@ -946,11 +998,33 @@ fn safe_mode_state(_: &mut Session, _: &Value) -> Result { Ok(json!({ "active": safe_mode::active(), "source": safe_mode::source().map(safe_mode::Source::name), - "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::>(), - "pendingItem": safe_mode::previous().map(|i| i.to_json()), + "disabled": safe_mode::disabled().iter().map(shown_item).collect::>(), + "pendingItem": safe_mode::previous().map(|i| shown_item(&i)), })) } +/// The file of a plug-in `plugin.safeMode` lists as `file`, or `plugin.list` as `source`: one they name +/// by its file name (outside the automation read roots) is found by that name, when no other listed +/// file has it. +fn listed_file(file: String) -> String { + let mut listed: Vec = safe_mode::disabled() + .into_iter() + .chain(safe_mode::previous()) + .filter_map(|i| i.file) + .chain(registry::list().iter().filter_map(|p| p.source().map(str::to_string))) + .collect(); + listed.sort(); + listed.dedup(); + if listed.contains(&file) { + return file; + } + let mut named = listed.into_iter().filter(|f| shown_file(f) == file); + match (named.next(), named.next()) { + (Some(f), None) => f, + _ => file, + } +} + /// `plugin.setEnabled`: turns a plug-in on or off for the next launch, by `id` or by `file` (a /// plug-in that stopped a launch while loading may be known by its file alone). It neither loads /// nor unloads anything now, and uninstalls nothing. @@ -972,7 +1046,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { let stored = if enabled { s.stored_plugin_file(&id).map(safe_mode::Item::file) } else { None }; (registry::get(&id).map(|p| safe_mode::Item::of(&p)).unwrap_or(safe_mode::Item { id: Some(id), ..Default::default() }), stored) } - (None, Some(file)) => (safe_mode::Item::file(file), None), + (None, Some(file)) => (safe_mode::Item::file(listed_file(file)), None), (None, None) => return Err(bad(C, "give `id` or `file` (see plugin.safeMode)")), }; let items: Vec<&safe_mode::Item> = std::iter::once(&item).chain(stored.as_ref()).collect(); @@ -986,7 +1060,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { // Without a plug-in folder (a session confined to automation roots), or when the list there // can't take the change, the change stays in this process and goes with the next change that saves. let saved = !changed || s.store_disabled_plugins(); - Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::>()})) + Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(shown_item).collect::>()})) } fn info(_: &mut Session, p: &Value) -> Result { @@ -1055,7 +1129,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null (under automation read roots, a folder outside them is null, and a failed file and a plug-in's source outside them are named by their file name), safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -1127,7 +1201,7 @@ pub fn specs() -> Vec { "Safe Mode", [], None, - "{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command", + "{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file; under automation read roots, a file outside them is named by its file name here and in pendingItem, and plugin.setEnabled takes that name), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command", always, safe_mode_state ), @@ -1136,7 +1210,7 @@ pub fn specs() -> Vec { "Enable on Re-launch", [], None, - "{id | file: a plug-in as plugin.safeMode's disabled lists it, enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}", + "{id | file: a plug-in as plugin.safeMode's disabled lists it or as plugin.list's source (under automation read roots, a file outside them by the file name shown, when no other listed file has it), enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}", always, set_enabled ), diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index fce76bcc27..f0087595dd 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -97,6 +97,10 @@ pub trait RecoveryStore: Send + Sync { fn remove(&self, name: &str) -> std::result::Result<(), String>; /// Where the entries are, as the user knows it (a folder, "browser storage"). fn location(&self) -> String; + /// Is [`RecoveryStore::location`] a folder on this computer (default: no)? + fn in_folder(&self) -> bool { + false + } /// The areas in the store (default: those with entries). fn areas(&self) -> std::result::Result, String> { let names = self.list()?; @@ -477,6 +481,9 @@ impl RecoveryStore for FolderStore { fn location(&self) -> String { self.0.to_string_lossy().into_owned() } + fn in_folder(&self) -> bool { + true + } fn areas(&self) -> std::result::Result, String> { Self::names(&self.0, true) } @@ -938,6 +945,21 @@ fn sweep(store: &dyn RecoveryStore, area: &str) { } } +/// The document's file `path` as a reply names it: under automation read roots, only inside them. +fn shown_path(path: Option) -> Option { + path.filter(|p| crate::file_access::check_read(p).is_ok()) +} + +/// Where `store` keeps its copies, as a reply names it: a recovery folder outside the automation +/// read roots is `null`. +fn shown_location(store: &dyn RecoveryStore) -> Value { + let location = store.location(); + if store.in_folder() && crate::file_access::check_read(&location).is_err() { + return Value::Null; + } + json!(location) +} + fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta { store.read(&format!("{file}{META_EXT}")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() } @@ -1235,7 +1257,7 @@ fn save(s: &mut Session, _: &Value) -> Result { } forget_clean(s); heartbeat(s); - Ok(json!({ "saved": saved, "skipped": skipped, "location": store.location() })) + Ok(json!({ "saved": saved, "skipped": skipped, "location": shown_location(store.as_ref()) })) } /// Who an area of the store belongs to. @@ -1361,7 +1383,7 @@ fn list(s: &mut Session, _: &Value) -> Result { let meta = read_meta(store.as_ref(), f); let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title }; rows.push(json!({ - "file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved, + "file": f, "title": title, "path": shown_path(meta.path), "format": meta.format, "saved": meta.saved, "open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running, })); } @@ -1370,7 +1392,7 @@ fn list(s: &mut Session, _: &Value) -> Result { tidy(store.as_ref(), &area, hold); } } - Ok(json!({ "copies": rows, "location": store.location() })) + Ok(json!({ "copies": rows, "location": shown_location(store.as_ref()) })) } /// A gone app's area to act on: its name, the copies to act on, and its lock. @@ -1483,7 +1505,10 @@ fn restore_one(s: &mut Session, store: &Arc, file: &str) -> R // The copy stays until the document is saved or closed. st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() }); let title = st.title(); - Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path, "warnings": warnings }), links.to_json())) + Ok(super::fileio::merge( + json!({ "index": index, "title": title, "file": mine, "path": shown_path(meta.path), "warnings": warnings }), + links.to_json(), + )) } fn restore(s: &mut Session, p: &Value) -> Result { @@ -1527,7 +1552,7 @@ pub fn specs() -> Vec { "Save Recovery Data", [], None, - "{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)", + "{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web; null under automation read roots when the folder is outside them)", has_store, save ), @@ -1536,7 +1561,7 @@ pub fn specs() -> Vec { "Recovery Data", [], None, - "{} the recovery copies in the store → {copies: [{file (\"/\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash", + "{} the recovery copies in the store → {copies: [{file (\"/\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash; under automation read roots, path is null when the document's file is outside them, and location when the recovery folder is", has_store, list ), @@ -1545,7 +1570,7 @@ pub fn specs() -> Vec { "Restore Recovered Documents", [], None, - "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \" [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed", + "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \" [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}; under automation read roots, path is null when the original file is outside them. The copy moves to this app's area and stays until the document is saved or closed", has_store, restore ), diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 8c86f0dfe3..44a14dbe34 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -233,6 +233,43 @@ impl Session { self.scripts_dir.as_deref().filter(|_| !cfg!(target_arch = "wasm32")) } + /// The last run's report ([`Session::script_report`]) as a reply shows it: under automation read + /// roots, a file the run wrote outside them (`written`) and the file a failed run stopped in + /// (`file`, also in the run's error line in `console`) are named by their file name. What the + /// script wrote itself (`log`, its output lines) stays as written. + pub fn shown_script_report(&self) -> Option { + let mut report = self.script_report()?.clone(); + if let Some(written) = report.get_mut("written").and_then(Value::as_array_mut) { + for path in written.iter_mut() { + if let Some(name) = path.as_str().and_then(hidden_name) { + *path = json!(name); + } + } + } + if let Some(file) = report.get("file").and_then(Value::as_str).map(str::to_string) + && let Some(name) = hidden_name(&file) + { + // The run's error lines come after every line the script wrote. They are written again + // from the report with the file's name, which also covers a line the console cut inside + // the path. A console that dropped its oldest lines may hold only the last of them. + let before = script::error_lines(&report); + report["file"] = json!(name); + if let Some(message) = report.get("message").and_then(Value::as_str) { + report["message"] = json!(message.replace(&file, &name)); + } + let after = script::error_lines(&report); + if let Some(lines) = report.pointer_mut("/console/lines").and_then(Value::as_array_mut) { + let kept = (1..=before.len().min(lines.len())) + .rev() + .find(|&n| lines.iter().rev().take(n).map(Value::as_str).eq(before.iter().rev().take(n).map(|l| Some(l.as_str())))) + .unwrap_or(0); + lines.truncate(lines.len() - kept); + lines.extend(after.iter().skip(after.len().saturating_sub(kept)).map(|l| json!(l))); + } + } + Some(report) + } + /// 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 { @@ -248,6 +285,13 @@ impl Session { } } +/// The file name a reply names `path` by when the file is outside the automation read roots; `None` +/// when a reply may name it whole. +fn hidden_name(path: &str) -> Option { + let shown = super::fileio::shown_path(path); + (shown != path).then(|| shown.into_owned()) +} + fn list_installed(s: &mut Session, _: &Value) -> Result { let Some(root) = s.scripts_dir() else { return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false})); diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 0821a11e71..0da75a42ed 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -2116,6 +2116,27 @@ fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) { host.console.borrow_mut().push(line, cut); } +/// The error of the failed run `report` describes, as its console lines give it; `name` is the +/// run's name in those lines ([`console::line_name`]). +fn error_text(name: &str, report: &Value) -> String { + let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); + let message = report["message"].as_str().unwrap_or_default(); + match report["file"].as_str() { + Some(file) if line > 0 => format!("{name}: {file}:{line}:{column}: {message}"), + _ if line > 0 => format!("{name}:{line}:{column}: {message}"), + _ => format!("{name}: location unavailable: {message}"), + } +} + +/// The console lines of the error of the failed run `report` describes, each as the console keeps +/// it, cut to its limit. +pub(crate) fn error_lines(report: &Value) -> Vec { + let name = console::line_name(report["name"].as_str().unwrap_or_default()); + let mut lines = vec![]; + each_line(&error_text(&name, report), |line, _| lines.push(line)); + lines +} + /// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run /// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report /// line in the session's console. Adds the run's time, host calls by kind, slowest commands, @@ -2130,14 +2151,7 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console:: } } if report["ok"] == false { - let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); - let message = report["message"].as_str().unwrap_or_default(); - let text = match report["file"].as_str() { - Some(file) if line > 0 => format!("{}: {file}:{line}:{column}: {message}", host.name), - _ if line > 0 => format!("{}:{line}:{column}: {message}", host.name), - _ => format!("{}: location unavailable: {message}", host.name), - }; - each_line(&text, |line, cut| line_to(s, host, console::Entry::Error(line), cut)); + each_line(&error_text(&host.name, report), |line, cut| line_to(s, host, console::Entry::Error(line), cut)); } let timing = clock().is_some(); let ms = millis(host.run_nanos.get()); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a1e615a08d..76df1c2194 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1433,6 +1433,92 @@ fn plugins_folder_set_again_after_clearing_installs_its_plugins() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// Under automation read roots, `plugin.list` names the plug-in folder last loaded, its failed +/// files and a plug-in's source file only inside them: a folder outside is `null`, and a file +/// outside is named by its file name. +#[test] +fn plugin_list_leaves_out_paths_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + let base = crate::tests_links::Folder::new("plugin-list-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let id = "test.engine.listroots"; + let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); + std::fs::write(outside.join("hidden.wasm"), wat::parse_str(src).unwrap()).unwrap(); + std::fs::write(outside.join("broken.wasm"), b"\0asm?").unwrap(); + let folder = outside.to_string_lossy().to_string(); + let mut s = session(); + let r = run(&mut s, "plugin.reload", json!({"path": folder})); + assert_eq!(r["loaded"], json!([id]), "{r}"); + let broken = outside.join("broken.wasm").to_string_lossy().to_string(); + let source = outside.join("hidden.wasm").to_string_lossy().to_string(); + let entry = |list: &Value| list["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap(); + + // The person sees the paths. + let person = run(&mut s, "plugin.list", json!({})); + assert_eq!((&person["folder"]["folder"], &person["folder"]["failed"][0]["file"]), (&json!(folder), &json!(broken)), "{person}"); + assert_eq!(entry(&person)["source"], json!(source)); + + // The client sees their names. + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + let report = &listed["folder"]; + assert_eq!((&report["folder"], &report["loaded"], &report["failed"][0]["file"]), (&Value::Null, &json!([id]), &json!("broken.wasm")), "{listed}"); + assert_eq!(entry(&listed)["source"], "hidden.wasm"); + let info = confine(roots.as_ref(), || s.execute("plugin.info", &json!({"id": id}))).unwrap(); + assert_eq!(info["source"], "hidden.wasm"); + let outside_text = json!(folder).to_string(); + for reply in [&listed, &info] { + assert!(!reply.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {reply}"); + } + + // A read root holding the folder shows it whole. + let wider = AutomationRoots::new(&[&outside], &[]).unwrap(); + let listed = confine(wider.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + assert_eq!(listed["folder"]["folder"], json!(folder), "{listed}"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// Under automation read roots, a plug-in turned off for the next launch is listed with a file +/// outside them named by its file name, and `plugin.setEnabled` turns it on by that name. +#[test] +fn disabled_plugins_name_files_outside_the_read_root_by_their_names() { + if !in_own_process("disabled_plugins_name_files_outside_the_read_root_by_their_names") { + return; + } + use crate::file_access::{AutomationRoots, confine}; + let base = crate::tests_links::Folder::new("plugin-disabled-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let id = "test.engine.disabledroots"; + let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); + let file = outside.join("off.wasm"); + std::fs::write(&file, wat::parse_str(src).unwrap()).unwrap(); + let file = file.to_string_lossy().to_string(); + let mut s = session(); + run(&mut s, "plugin.install", json!({"path": file})); + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + // The client turns it off by the source plugin.list shows. + let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + let source = listed["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).unwrap()["source"].clone(); + assert_eq!(source, "off.wasm", "{listed}"); + confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": source, "enabled": false}))).unwrap(); + assert_eq!(run(&mut s, "plugin.safeMode", json!({}))["disabled"][0]["file"], json!(file), "the person sees the path"); + + let outside_text = json!(outside.to_string_lossy()).to_string(); + for cmd in ["plugin.list", "plugin.safeMode"] { + let r = confine(roots.as_ref(), || s.execute(cmd, &json!({}))).unwrap(); + assert_eq!(r["disabled"][0]["file"], "off.wasm", "{cmd}: {r}"); + assert!(!r.to_string().contains(outside_text.trim_matches('"')), "{cmd}: no path outside the root: {r}"); + } + let r = confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": "off.wasm", "enabled": true}))).unwrap(); + assert_eq!((&r["changed"], &r["disabled"]), (&json!(true), &json!([])), "{r}"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// #2541: a plug-in installed in a session with a plug-in store is installed again in the next /// session that attaches the store, and Remove Plug-in takes it out of the store. #[test] diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 8248f12a48..1f3867a622 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -1545,6 +1545,71 @@ impl RecoveryStore for DiesAfterDocument { } } +/// Under automation read roots, `file.recovery.save`, `file.recovery.list` and +/// `file.recovery.restore` name a document's file and the recovery folder only inside them. +#[test] +fn recovery_replies_leave_out_paths_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + use serde_json::Value; + let base = crate::tests_links::Folder::new("recovery-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let store: Arc = Arc::new(FolderStore::new(outside.join("recovery"))); + let location = outside.join("recovery").to_string_lossy().to_string(); + let kept = inside.join("kept.vectorcraft").to_string_lossy().to_string(); + let hidden = outside.join("hidden.vectorcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + for path in [&kept, &hidden] { + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + } + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let saved = confine(roots.as_ref(), || s.execute("file.recovery.save", &json!({}))).unwrap(); + assert_eq!(saved["location"], Value::Null, "{saved}"); + s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap(); + assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["location"], json!(location), "the person sees the folder"); + + let sorted = |v: &Value, key: &str| -> Vec { + let mut out: Vec = v[key].as_array().unwrap().iter().map(|c| c["path"].to_string()).collect(); + out.sort(); + out + }; + let both = |first: Value| { + let mut out = vec![first.to_string(), json!(kept).to_string()]; + out.sort(); + out + }; + assert_eq!(sorted(&s.execute("file.recovery.list", &json!({})).unwrap(), "copies"), both(json!(hidden))); + let listed = confine(roots.as_ref(), || s.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!((sorted(&listed, "copies"), &listed["location"]), (both(Value::Null), &Value::Null), "{listed}"); + let outside_text = json!(outside.to_string_lossy()).to_string(); + assert!(!listed.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {listed}"); + // A relative recovery folder is checked as the file system reads it; a store that is no folder + // keeps its name. + let mut relative = Session::new(); + relative.recovery.set_store(Arc::new(FolderStore::new("vc-recovery-relative-folder"))); + let l = confine(roots.as_ref(), || relative.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!(l["location"], Value::Null, "{l}"); + let (mut memory, _) = session(); + let l = confine(roots.as_ref(), || memory.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!(l["location"], "memory", "{l}"); + + // After a crash, the copies restored. + drop(s); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + let r = confine(roots.as_ref(), || s.execute("file.recovery.restore", &json!({}))).unwrap(); + assert_eq!(sorted(&r, "restored"), both(Value::Null), "{r}"); + let mut opened: Vec> = s.documents().iter().map(|d| d.path.clone()).collect(); + opened.sort(); + let mut files = vec![Some(hidden), Some(kept)]; + files.sort(); + assert_eq!(opened, files, "the documents keep their files"); +} + /// A crash between writing a copy's document and its details restores the new document with /// every image, the one placed since the last copy too (and the first copy likewise). #[test] diff --git a/crates/engine/src/tests/script_console.rs b/crates/engine/src/tests/script_console.rs index 27f53cef07..48d5c8ed0a 100644 --- a/crates/engine/src/tests/script_console.rs +++ b/crates/engine/src/tests/script_console.rs @@ -265,3 +265,31 @@ fn the_slowest_commands_are_the_longest_in_all_then_by_id() { assert_eq!(ids, ["b.two", "a.one", "c.three", "f", "e"]); assert_eq!((slow[0].calls, slow[0].total_ms, slow[0].max_ms), (3, 9, 4)); } + +/// Under automation read roots, the error lines of a failed run name the included file it stopped +/// in by its file name also when the console cut a line inside the file's path. +#[test] +fn a_cut_error_line_leaves_out_an_included_files_folder_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + let base = crate::tests_links::Folder::new("script-report-cut-line"); + let inside = base.0.join("in"); + std::fs::create_dir_all(&inside).unwrap(); + // A path longer than a console line, as the report of a failed run names it. + let file = base.0.join("out").join("d".repeat(MAX_LINE_BYTES)).join("bad.jsxinc").to_string_lossy().to_string(); + let mut report = json!({"ok": false, "name": "t.js", "file": file, "line": 2, "column": 7, "message": format!("Error: stopped\nin {file}")}); + let error = script::error_lines(&report); + assert!(error.len() == 2 && error.iter().all(|l| l.len() <= MAX_LINE_BYTES && l.ends_with(CUT_MARK)), "both lines are cut: {error:?}"); + let written: Vec = std::iter::once("written by the script".to_string()).chain(error).collect(); + report["console"] = json!({"lines": written, "droppedLines": 0, "truncatedLines": 2}); + let mut s = Session::new(); + s.script_report = Some(report.clone()); + + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let shown = confine(roots.as_ref(), || s.shown_script_report()).unwrap(); + assert_eq!((&shown["file"], &shown["message"]), (&json!("bad.jsxinc"), &json!("Error: stopped\nin bad.jsxinc")), "{shown}"); + assert_eq!(shown["console"]["lines"], json!(["written by the script", "t.js: bad.jsxinc:2:7: Error: stopped", "in bad.jsxinc"]), "{shown}"); + let outside = json!(base.0.join("out").to_string_lossy()).to_string(); + assert!(!shown.to_string().contains(outside.trim_matches('"')), "no path outside the root: {shown}"); + // The person sees the report as it is. + assert_eq!(s.shown_script_report(), Some(report)); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 11edb6de6e..ef6a6d20c8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -253,7 +253,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { } let mut inspected = json!({ "scriptRunning": app.session.script_running(), - "scriptReport": app.session.script_report(), + "scriptReport": app.session.shown_script_report(), "tool": app.session.tool_id(), "toolOptions": app.session.tool_options(), "ui": ui, diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 26406a4992..3a84e0b94d 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -567,6 +567,50 @@ fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect`'s `scriptReport` names a file the person's script wrote outside the read root, and +/// the included file a failed run stopped in (also in its error line), by its file name; the person +/// sees the paths. +#[test] +fn ui_inspect_script_report_leaves_out_files_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-script-report"); + let (kept, hidden) = (s(base.join("in/kept.svg")), s(base.join("outside/hidden.svg"))); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let source = format!( + "vc.run('document.export', {{path: {kept}, format: 'svg'}}); vc.run('document.export', {{path: {hidden}, format: 'svg'}});", + kept = json!(kept), + hidden = json!(hidden) + ); + let finish = |app: &mut VectorcraftApp, params: Value| { + app.session.execute("script.run", ¶ms).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + }; + finish(&mut app, json!({"source": source, "name": "t.js"})); + let ctx = egui::Context::default(); + let outside = json!(s(base.join("outside"))).to_string(); + let person = crate::control::inspect(&app, &ctx); + assert_eq!(person["scriptReport"]["written"], json!([kept, hidden]), "{person}"); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["scriptReport"]["written"], json!([kept, "hidden.svg"]), "{r}"); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + assert_eq!(app.session.script_report().unwrap()["written"], json!([kept, hidden]), "the report is not changed"); + + // A run that fails in a file it includes. + std::fs::write(base.join("outside/run.jsx"), "#include \"bad.jsxinc\"\n").unwrap(); + std::fs::write(base.join("outside/bad.jsxinc"), "\nthrow new Error('stopped here');\n").unwrap(); + finish(&mut app, json!({"path": s(base.join("outside/run.jsx"))})); + let person = crate::control::inspect(&app, &ctx); + assert_eq!(person["scriptReport"]["file"], json!(s(base.join("outside/bad.jsxinc"))), "{person}"); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["scriptReport"]["file"], "bad.jsxinc", "{r}"); + assert!(r["result"]["scriptReport"]["console"].to_string().contains("bad.jsxinc:2:"), "{r}"); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { diff --git a/docs/mcp.md b/docs/mcp.md index baa2631f7b..86b5a4af30 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -170,6 +170,17 @@ the suite) keep the agent to the folders you give it: `null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and `prefs.reset`, an export preset in `screenExportPresets` leaves out its `folder` when the folder is outside the read roots, and the preset stays. The preferences themselves are not changed. +- `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths + outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in + folder last loaded is `null` in `folder.folder`, and a failed file in `folder.failed` and a plug-in's `source` + (also in `plugin.info`) are named by their file name, in the errors too. A plug-in turned off for the next + launch (`disabled`, and `pendingItem` in `plugin.safeMode`) has a file outside the read roots named by its file + name. `plugin.setEnabled {file}` takes that name, and a plug-in's `source` name, when no other listed file has + it. In `file.recovery.list` and `file.recovery.restore`, a + document's original file in `path` is `null`, and so is the recovery folder in `location` (`file.recovery.save` + too); the restored documents keep their files. In `scriptReport`, a file the run wrote (`written`) and the + included file a failed run stopped in (`file`, also in `message` and the run's error lines in `console`) are named + by their file name; what the script wrote itself (`log` and its output lines) stays as written. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f28078ec33..320a43ccc2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -766,6 +766,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. + - Plug-in, recovery and script report replies under `--automation-read-root`: `plugin.list`, `plugin.info` and `plugin.safeMode` name the plug-in folder, failed files, plug-in sources and disabled plug-ins' files outside the read roots by file name or `null`, `file.recovery.*` leave out a document's original file and the recovery folder outside them, and `ui.inspect`'s `scriptReport` names files a run wrote outside them by file name. `crates/engine/src/tests/plugins.rs` (`plugin_list_leaves_out_paths_outside_the_read_root`), `crates/engine/src/tests/recovery.rs` (`recovery_replies_leave_out_paths_outside_the_read_root`), `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_script_report_leaves_out_files_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). From c024fca8f3a5017809d8894ba51cede4d0e8d0c5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:22:15 -0800 Subject: [PATCH 56/79] =?UTF-8?q?Keyboard=20Shortcuts:=20a=20command=20tha?= =?UTF-8?q?t=20a=20menu=20bar=20item=20runs=20can=20take=20a=20shortcut,?= =?UTF-8?q?=20Set=20Corners=E2=80=A6=20among=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shortcut given to Set Corners… (`addon.pathtools.cornerSet`) ran nothing, on a single key or a chord. The keymap is built from the list in Edit › Keyboard Shortcuts, and that list left the command out. An engine command without a menu path of its own was listed only when it had a default key, or when it was an undoable command, not an alias, whose params doc ends each field with `?`. A UI command was listed only when it had a default key or a params doc of `{}` or `{path…}`, or was Rename. Set Corners… needs a radius, so it was left out. The same rules left out other commands that items of the menu bar run: - View › Perspective Grid's Lock Grid, Lock Station Point, Show Rulers and Snap to Grid, and Object › Perspective's Attach to Active Plane, Move Plane to Match Object and Edit Text. - Edit › Edit Custom Dictionary…, a query. - UI commands: Recolor Artwork…, the Edit menu's four preset dialogs, Apply SVG Filter…, Export As…, Export Selection…, Edit Original, Apply Bullets, Apply Numbers, the four library saves, and the Window menu's New Toolbar…, Double Column, Keep Tools Beside the Canvas, New Workspace…, Float in Window and Consolidate All Windows. Such a command is now listed when an item of the menu bar runs it as a key press would: with no params, or with the one set of params all its items give. `needs_a_value` already used this check, and it moves into `a_menu_bar_item_runs` so that both use it. The command is listed in the group named by the first part of its id, next to the commands of its family that were listed before (Set Corners… under Addon with Corner Tool, the grid toggles under Perspective). File › Export Selection… was the row of `document.exportSelection`, which no item of the menu bar runs. That row now runs `file.exportSelection`, as the menu item does, and `document.exportSelection` is listed as Document › Export Selection…. A shortcut already given to it keeps running it. The Window menu's Learn item gets no row, because the Learn panel's row already has its name. Tests: on F and on a chord, Set Corners…, Edit Custom Dictionary…, Recolor Artwork…, the four preset dialogs and Apply SVG Filter… open the dialog a click on their menu item opens. Snap to Grid turns off and on from F, and Export Selection… on F opens Export for Screens. Every command the menu bar runs this way is listed, none of them shares its group and label with another entry, and a shortcut on four of them is kept through a saved set's export and import. The other Object › Plug-ins dialogs, Select by Link Status… and Composite Fonts… already opened from a shortcut and are checked the same way. --- crates/ui-egui/src/shortcut_editor.rs | 142 ++++++++++++++++++++++---- crates/ui-egui/src/shortcuts/tests.rs | 67 ++++++++++-- docs/mcp.md | 24 +++++ 3 files changed, 209 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 7ada99ee5c..36ddfe8cb5 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -449,17 +449,17 @@ fn fixed_entries() -> &'static (Vec, Vec) { .map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true }) .collect(); let mut seen = HashSet::new(); + // The commands listed only because an item of the menu bar runs them as a key press would + // (Set Corners…). + let mut by_the_menu_bar = HashSet::new(); for c in vectorcraft_engine::command_specs() { - // Commands outside the menus (panel menu items) are - // listed when a key alone can run them; queries and aliases never are. - let listed = if c.menu.is_empty() { - c.shortcut.is_some() || (c.journal && !vectorcraft_engine::cmd::is_alias(c.id) && runs_without_params(c.params)) - } else { - true - }; - if !listed || !seen.insert(c.id) { + let own = listed_on_its_own(c); + if !(own || a_menu_bar_item_runs(c.id, c.params)) || !seen.insert(c.id) { continue; } + if !own { + by_the_menu_bar.insert(c.id); + } let group = if c.menu.is_empty() { id_group(c.id) } else { c.menu.join(" › ") }; v.push(Entry { key: c.id.into(), label: c.label.into(), group, is_tool: false }); } @@ -472,13 +472,36 @@ fn fixed_entries() -> &'static (Vec, Vec) { // Each effect of the Effect menu comes next, by its own key (#3296): see [`snapshot`]. let mut tail = vec![]; for (id, label, sc, params) in menus::UI_COMMANDS { - // Rename has no default key, and a key press names the selection: it needs no `id`. - let key_runs_it = !sc.is_empty() || params.starts_with("{}") || params.starts_with("{path") || *id == "ui.rename"; - if !seen.insert(id) || !key_runs_it { + let own = ui_listed_on_its_own(id, sc, params); + if !seen.insert(id) || !(own || a_menu_bar_item_runs(id, params)) { continue; } + if !own { + by_the_menu_bar.insert(id); + } tail.push(Entry { key: id.to_string(), label: label.to_string(), group: id_group(id), is_tool: false }); } + // A command listed only because an item of the menu bar runs it can have the group and + // label of another entry. When that entry is an engine command that no item of the menu bar + // runs, the command the item runs keeps the name and the other entry is shown in the group + // of its id: File › Export Selection… runs `file.exportSelection`, as the menu item does, + // and `document.exportSelection` shows as Document › Export Selection…. Otherwise the + // command is left out: Window › Learn stays the Learn panel's entry. + let name = |e: &Entry| (e.group.clone(), e.label.clone()); + let new_names: HashSet<_> = v.iter().chain(&tail).filter(|e| by_the_menu_bar.contains(e.key.as_str())).map(name).collect(); + for e in v.iter_mut().chain(tail.iter_mut()) { + if new_names.contains(&name(e)) + && !by_the_menu_bar.contains(e.key.as_str()) + && vectorcraft_engine::find_command(&e.key).is_some() + && !menu_bar_items().contains_key(e.key.as_str()) + { + e.group = id_group(&e.key); + } + } + let names: HashSet<_> = v.iter().chain(&tail).filter(|e| !by_the_menu_bar.contains(e.key.as_str())).map(name).collect(); + let keep = |e: &Entry| !by_the_menu_bar.contains(e.key.as_str()) || !names.contains(&name(e)); + v.retain(keep); + tail.retain(keep); (v, tail) }) } @@ -694,17 +717,11 @@ pub(crate) fn menu_target(id: &str) -> Option<(String, Value)> { fn needs_a_value(key: &str) -> bool { static KEYS: OnceLock> = OnceLock::new(); KEYS.get_or_init(|| { - // A menu item that runs the command itself with params of its own counts only when the - // params doc names no required field: Edit › Find Next gives `find` empty, which it rejects. - let runs = |c: &vectorcraft_engine::CommandSpec| { - menu_bar_items().get(c.id).is_some_and(|ps| ps.iter().any(|(_, p)| is_bare(p))) - || menu_target(c.id).is_some_and(|(id, _)| id != c.id || !names_a_required_field(c.params)) - }; vectorcraft_engine::command_specs() .iter() .filter(|c| { c.shortcut.is_none() - && !runs(c) + && !a_menu_bar_item_runs(c.id, c.params) && !crate::dialogs::variables::opens(c.id) && (!c.journal || NEEDS_A_PARAM.contains(&c.id) || (!c.menu.is_empty() && names_a_required_field(c.params))) }) @@ -714,6 +731,31 @@ fn needs_a_value(key: &str) -> bool { .contains(key) } +/// Does an item of the menu bar run command `id`, whose params doc is `params`, as a key press runs +/// it: with no params, or with the one set of params all its items give ([`menu_target`])? A menu +/// item that runs the command itself with params of its own counts only when the params doc names +/// no required field: Edit › Find Next gives `find` empty, which it rejects. +fn a_menu_bar_item_runs(id: &str, params: &str) -> bool { + menu_bar_items().get(id).is_some_and(|ps| ps.iter().any(|(_, p)| is_bare(p))) + || menu_target(id).is_some_and(|(target, _)| target != id || !names_a_required_field(params)) +} + +/// Is engine command `c` listed in Edit › Keyboard Shortcuts whether or not an item of the menu bar +/// runs it? A command with a menu path of its own is. One without (a panel menu item) is when a key +/// alone can run it: it has a default key, or it is an undoable command, not an alias, whose params +/// doc ends each field with `?`. +fn listed_on_its_own(c: &vectorcraft_engine::CommandSpec) -> bool { + !c.menu.is_empty() || c.shortcut.is_some() || (c.journal && !vectorcraft_engine::cmd::is_alias(c.id) && runs_without_params(c.params)) +} + +/// Is UI command `id` (default key `sc`, params doc `params`) listed in Edit › Keyboard Shortcuts +/// whether or not an item of the menu bar runs it? It is when it has a default key or a params doc +/// of `{}` or `{path…}`. Rename has no default key, and a key press names the selection: it needs +/// no `id`. +fn ui_listed_on_its_own(id: &str, sc: &str, params: &str) -> bool { + !sc.is_empty() || params.starts_with("{}") || params.starts_with("{path") || id == "ui.rename" +} + /// Does a menu bar item run the command with no params, so that it does what its label says and opens /// no dialog? A command no menu bar item runs (the Crop Image item runs `ui.cropImage`), a menu item /// that gives it params, a [`NEEDS_A_PARAM`] command and a field a call must give each rule it out. @@ -2310,6 +2352,70 @@ mod tests { assert_eq!(effective_in(&ov, "object.isolate").as_deref(), Some("Cmd+Alt+Shift+F11")); } + /// Every engine or UI command that an item of the menu bar runs as a key press runs it (without + /// params, or with the one set of params all its items give) is listed in Edit › Keyboard + /// Shortcuts, also when the command has no menu path of its own: Set Corners…, whose params doc + /// names a field a call must give, the Perspective Grid toggles, Edit Custom Dictionary…, a + /// query, and Recolor Artwork…, a UI command. Such a command is listed in the group of its id, + /// next to the commands of its family that were listed before. A shortcut given to one survives + /// a saved set's round trip. Find Next is left out: its menu item gives `find` empty, which the + /// command rejects. When another entry has the same group and label, the command the menu item + /// runs keeps the name if no menu item runs the other: File › Export Selection… is + /// `file.exportSelection`, and `document.exportSelection` moves to Document. Otherwise the + /// command is left out: Window › Learn is the Learn panel's entry, not `window.learn`. + #[test] + fn every_command_the_menu_bar_runs_can_be_given_a_shortcut() { + let none = Overrides::new(); + let mut missing: Vec<&str> = menu_bar_items() + .iter() + .filter(|(id, items)| items.iter().any(|(_, p)| is_bare(p)) || menu_target(id).is_some()) + .map(|(id, _)| *id) + .filter(|id| *id != "edit.findNext" && *id != "window.learn") + .filter(|id| vectorcraft_engine::find_command(id).is_some() || menus::UI_COMMANDS.iter().any(|c| c.0 == *id)) + .filter(|id| !entry(id).is_some_and(|e| listed(&none, &e))) + .collect(); + missing.sort_unstable(); + assert!(missing.is_empty(), "{missing:?}"); + assert!(entry("window.learn").is_none(), "Window › Learn is the Learn panel's entry"); + let shown = |id: &str| entry(id).map(|e| (e.group, e.label)); + assert_eq!(shown("file.exportSelection"), Some(("File".into(), "Export Selection…".into()))); + assert_eq!(shown("document.exportSelection"), Some(("Document".into(), "Export Selection…".into()))); + // No command listed only because an item of the menu bar runs it shares its group and label + // with another entry. + let own = |k: &str| { + vectorcraft_engine::command_specs() + .iter() + .find(|c| c.id == k) + .map(listed_on_its_own) + .or_else(|| menus::UI_COMMANDS.iter().find(|c| c.0 == k).map(|(id, _, sc, p)| ui_listed_on_its_own(id, sc, p))) + .unwrap_or(true) + }; + let (v, tail) = fixed_entries(); + let mut names: HashMap<(&str, &str), Vec<&str>> = HashMap::new(); + for e in v.iter().chain(tail) { + names.entry((e.group.as_str(), e.label.as_str())).or_default().push(e.key.as_str()); + } + let shared: Vec<_> = names.iter().filter(|(_, keys)| keys.len() > 1 && keys.iter().any(|k| !own(k))).collect(); + assert!(shared.is_empty(), "{shared:?}"); + for (id, group) in [ + (crate::addons::pathtools::set_corners::COMMAND, "Addon"), + ("perspective.grid.snap", "Perspective"), + (crate::dialogs::custom_dictionary::COMMAND, "Text"), + ("ui.recolorDialog", "Ui"), + ] { + let e = entry(id).unwrap_or_else(|| panic!("{id} is listed")); + assert_eq!(e.group, group, "{id}"); + } + for id in + [crate::addons::pathtools::set_corners::COMMAND, "perspective.grid.lock", crate::dialogs::custom_dictionary::COMMAND, "ui.recolorDialog"] + { + let mut ov = Overrides::new(); + assign(&mut ov, id, Some("Cmd+Alt+Shift+F11"), false).unwrap(); + let (_, back) = import_json(&export_json("Mine", &ov)).unwrap(); + assert_eq!(effective_in(&back, id).as_deref(), Some("Cmd+Alt+Shift+F11"), "{id}"); + } + } + /// `shortcuts.set`, `shortcuts.list` and `shortcuts.preset` reject a supplied param of the wrong /// type with an error naming it, and change nothing; an omitted or null param keeps its default. #[test] diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 8d32a4de22..d598c6bb19 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -294,9 +294,13 @@ fn a_shortcut_runs_what_its_menu_item_runs() { /// Commands whose menu item opens a slider or a dialog before anything changes: the sliders of /// Smooth and Simplify, Offset Path, the Object › Transform dialogs, Average, Split Into Grid, -/// Vector Halftone, Rearrange All Artboards, Pathfinder Options, Arrange in Grid, Divide into -/// Dashes, Find Nearest Swatch, and four "…" items whose menu entry gives params. -const MENU_DIALOG_COMMANDS: [&str; 22] = [ +/// Vector Halftone, Rearrange All Artboards, Pathfinder Options, the Object › Plug-ins dialogs +/// (Arrange in Grid, Replace with Top Object, the size, opacity and sort dialogs of the Arrange +/// add-on, Set Corners…, Divide into Dashes, Find Nearest Swatch, the swatch legend, the type +/// tools, the select add-on and Select by Link Status…), four "…" items whose menu entry gives +/// params, Edit Custom Dictionary…, Composite Fonts…, Recolor Artwork…, the Edit menu's preset +/// dialogs and Apply SVG Filter…. +const MENU_DIALOG_COMMANDS: [&str; 39] = [ "object.path.smooth", "object.path.simplify", "object.path.offsetPath", @@ -313,12 +317,29 @@ const MENU_DIALOG_COMMANDS: [&str; 22] = [ "artboard.rearrange", "object.pathfinder.options", "addon.arrange.grid", + "addon.arrange.replace", + "addon.arrange.size", + "addon.arrange.randomizeOpacity", + "addon.arrange.sort", + crate::addons::pathtools::set_corners::COMMAND, crate::addons::pathtools::dashes::COMMAND, crate::addons::color::nearest::COMMAND, + crate::addons::color::legend::COMMAND, + crate::addons::typetools::COMMAND, + crate::addons::select::COMMAND, + crate::addons::select::link_status::COMMAND, "object.path.cleanUp", "object.mesh.create", "type.smartPunctuation", "edit.findReplace", + crate::dialogs::custom_dictionary::COMMAND, + crate::dialogs::composite_fonts::COMMAND, + "ui.recolorDialog", + "ui.flattenerPresetsDialog", + "ui.printPresetsDialog", + "ui.pdfPresetsDialog", + "ui.perspectivePresetsDialog", + "ui.svgFilterDialog", ]; /// F pressed without a modifier. @@ -342,8 +363,33 @@ fn undo_len(app: &VectorcraftApp) -> usize { app.session.active().unwrap().history.undo.len() } +/// View › Perspective Grid › Snap to Grid, whose command has no menu path of its own, turns off and +/// on again from a shortcut given to it, as its menu item does. +#[test] +fn a_shortcut_on_a_perspective_grid_toggle_runs_it() { + let snaps = |app: &VectorcraftApp| crate::menus::checked(app, "perspective.grid.snap", &serde_json::Value::Null); + let mut app = app_with_shortcut("perspective.grid.snap", "F"); + assert_eq!(snaps(&app), Some(true), "on by default"); + frame(&mut app, press_f()); + assert_eq!(snaps(&app), Some(false), "F turns it off: {}", app.ui.status); + frame(&mut app, vec![]); + frame(&mut app, press_f()); + assert_eq!(snaps(&app), Some(true), "and on again: {}", app.ui.status); +} + +/// A shortcut on File › Export Selection… does what a click on the menu item does: it adds the +/// selection to Export for Screens' assets and opens Export for Screens. +#[test] +fn a_shortcut_on_export_selection_opens_export_for_screens() { + let mut app = app_with_shortcut("file.exportSelection", "F"); + frame(&mut app, press_f()); + assert_eq!(open_dialog(&app).as_deref(), Some("exportForScreens"), "{}", app.ui.status); +} + /// A command given a key without a modifier runs what its menu item runs, as it does on a chord: -/// its slider or dialog opens and nothing changes until it is confirmed. +/// its slider or dialog opens and nothing changes until it is confirmed. The dialog is the one a +/// click on the command's menu item opens (its item without params, else its first item), also for +/// commands without a menu path of their own (Set Corners…, Recolor Artwork…). #[test] fn a_single_key_shortcut_opens_the_dialog_its_menu_item_opens() { let chord = vec![ @@ -364,8 +410,17 @@ fn a_single_key_shortcut_opens_the_dialog_its_menu_item_opens() { }; let by_key = opened("F", press_f()); let by_chord = opened("Cmd+Alt+Shift+F9", chord.clone()); - if by_key.is_none() || by_key != by_chord { - failures.push(format!("{id}: F opens {by_key:?}, the chord {by_chord:?}")); + let mut app = app_with_a_selected_rectangle(); + // Type › Composite Fonts… shows while East Asian options are shown. + app.session.prefs.show_east_asian_options = true; + let items: Vec<_> = crate::menus::menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some(id)).collect(); + let item = items.iter().find(|e| e.params.as_object().is_none_or(|o| o.is_empty())).or(items.first()); + // Pathfinder Options… is an item of the Pathfinder panel's menu, which runs it without params. + let (menu_id, p) = item.map_or_else(|| (id.to_string(), json!({})), |item| crate::menus::click_target(&item.label, id, &item.params)); + crate::menus::invoke(&mut app, &menu_id, p); + let by_menu = app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.fields.get("__command").cloned())); + if by_key.is_none() || by_key != by_chord || by_chord != by_menu { + failures.push(format!("{id}: F opens {by_key:?}, the chord {by_chord:?}, the menu item {by_menu:?}")); } } assert!(failures.is_empty(), "{failures:#?}"); diff --git a/docs/mcp.md b/docs/mcp.md index 93ee31576b..a53916a5cf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4792,6 +4792,30 @@ They list such a command while the user has changed its shortcut (`overridden: t shortcut for that command runs what a click on the item runs: Object › Path › Clean Up…, Print Ink Fixes… and Edit › Find and Replace… open their dialogs. +A command without a menu path of its own is listed when an item of the menu bar runs it with no params, or when every +item that runs it gives it the same params. It is listed in the group named by the first part of its id. These +commands are listed this way: + +- `addon.pathtools.cornerSet` (Object › Plug-ins › Path Precision Tools › Set Corners…, which opens its dialog), + under Addon; +- `perspective.grid.lock`, `perspective.grid.lockStation`, `perspective.grid.rulers` and `perspective.grid.snap` + (View › Perspective Grid), and `perspective.attach`, `perspective.editText` and `perspective.plane.matchObject` + (Object › Perspective), under Perspective; +- `text.spelling.dictionary` (Edit › Edit Custom Dictionary…), under Text; +- the UI commands `ui.recolorDialog` (Edit › Edit Colors › Recolor Artwork…), `ui.flattenerPresetsDialog`, + `ui.printPresetsDialog`, `ui.pdfPresetsDialog`, `ui.perspectivePresetsDialog`, `ui.svgFilterDialog`, + `ui.saveSwatchLibrary`, `ui.saveBrushLibrary`, `ui.saveGraphicStyleLibrary` and `ui.saveSymbolLibrary`, under Ui; + `file.exportAs` and `file.exportSelection`, under File; `links.editOriginal`, under Links; `type.applyBullets` and + `type.applyNumbers`, under Type; and `window.toolbar.new`, `window.toolbarColumns`, `window.toolsBesideCanvas`, + `window.workspace.new`, `window.arrange.floatInWindow` and `window.arrange.consolidateAllWindows`, under Window. + +When such a command has the same group and label as an engine command that no item of the menu bar runs, the +command the menu item runs keeps the name, and the engine command is listed in the group named by the first part of +its id: File › Export Selection… runs `file.exportSelection`, as the menu item does, and `document.exportSelection` +is listed as Document › Export Selection…. A shortcut already given to `document.exportSelection` keeps running it. +When the other entry is anything else, the command is not listed: `window.learn` is left out, and Window › Learn is +the Learn panel's entry. + Each installed filter plug-in's Object › Plug-ins item is its own row, with id `plugin:` (the plug-in's id), its menu label and no default shortcut, so two filters with the same name have two rows. `shortcuts.set {id: "plugin:", shortcut}`, `shortcuts.list` and Import Shortcut Set use that id, `shortcuts.exportText` writes one line From 3adadd52f4daad74527b886ade6174cd6fb7c6eb Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:40:04 +0530 Subject: [PATCH 57/79] UI: a tap within 1 pt still selects a numeric field's value (#1326) Review feedback: with no tolerance, pen or trackpad jitter turned a tap into a drag and the value was no longer selected. A release within 1 pt of the press is a tap; anything further keeps the caret. Regression test: a_tap_with_a_pixel_of_jitter_still_selects_all_on_release. --- crates/ui-egui/src/tests/numfields.rs | 18 ++++++++++++++++-- crates/ui-egui/src/widgets/text.rs | 15 ++++++++++----- docs/gaps.md | 2 +- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index d7ceed77b9..0e1e794b8d 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -1361,8 +1361,8 @@ fn a_short_drag_released_as_a_click_keeps_its_caret() { assert_eq!(f.frame(vec![enter()], &draw), Some(1273456.0), "typing lands where the drag left the caret"); } -/// #1326: the smallest pointer movement egui still reports as a click (under the 3-point drag -/// threshold) must keep the caret too; only a press released where it went down selects all. +/// #1326: a pointer movement well under egui's click tolerance must keep the caret too; only a +/// press released within a point of where it went down selects all. #[test] fn a_sub_threshold_movement_released_as_a_click_keeps_its_caret() { let mut f = Field::new(); @@ -1391,6 +1391,20 @@ fn a_tap_without_movement_still_selects_all_on_release() { assert_eq!(f.selection(), Some((0, field_text(0).chars().count())), "a plain tap selects all"); } +/// A pen or trackpad tap often moves a pixel between press and release: it is still a tap. +#[test] +fn a_tap_with_a_pixel_of_jitter_still_selects_all_on_release() { + let mut f = Field::new(); + let draw = |ui: &mut egui::Ui| numeric_fixture(ui, 0); + f.frame(vec![], &draw); + let at = f.text_point(field_text(0), 2, 7.0); + f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw); + let jitter = egui::vec2(0.7, -0.6); + f.frame(vec![Event::PointerMoved(at + jitter)], &draw); + f.frame(vec![pointer_button(at + jitter, false)], &draw); + assert_eq!(f.selection(), Some((0, field_text(0).chars().count())), "a jittery tap selects all"); +} + #[test] fn keyboard_focus_keeps_selecting_the_entire_numeric_field() { for mode in 0..4 { diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index 5ede108998..eec3cd3a94 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -147,10 +147,14 @@ pub(crate) fn own_field_shift_enter(ui: &Ui, id: egui::Id, buf: &mut String, sho shift_enter_keeps_focus(ui, id) } +/// How far, in points, a focus click may move between press and release and still be a tap that +/// selects the whole numeric value. Covers pen and trackpad jitter; anything further is a drag. +const TAP_SLOP: f32 = 1.0; + /// Numeric fields select the whole text on keyboard focus, a completed focus click or a /// double-click. A pointer press keeps its caret so dragging selects from the pressed position, /// and a short drag the toolkit still counts as a click keeps it too (#1326): only a tap -/// releases into select-all. +/// (released within [`TAP_SLOP`] of its press) releases into select-all. pub(super) fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { let pending = resp.id.with("select-all-on-focus-click"); let press_pos = resp.id.with("select-all-on-focus-press"); @@ -171,13 +175,14 @@ pub(super) fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { }); } let clicked_focus = released && ui.data_mut(|d| d.remove_temp::(pending)).unwrap_or(false) && resp.clicked_by(egui::PointerButton::Primary); - // Any pointer movement from the press is a drag, not a tap. egui still reports a click for - // a movement under its click tolerance, and a release inside one character leaves no text - // selection, so select-all on release would replace the drag the user just made (#1326). + // A pointer released more than a point from its press is a drag, not a tap. egui still + // reports a click for a movement under its click tolerance, and a release inside one + // character leaves no text selection, so select-all on release would replace the drag the + // user just made (#1326). The one point of slack keeps a jittery pen or trackpad tap a tap. let dragged = clicked_focus && ui .data_mut(|d| d.remove_temp::(press_pos)) - .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 0.0)); + .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > TAP_SLOP)); if resp.lost_focus() { ui.data_mut(|d| { d.remove_temp::(pending); diff --git a/docs/gaps.md b/docs/gaps.md index be3ffe3f41..5e101fe46f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -320,7 +320,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** Divide splits filled paths along open cutting lines and curves (#1307), preserving the frontmost filled appearance, implicit filled closure, holes, unrelated objects and one Undo/Redo; the existing bounded-map fallback remains. -- **Done:** Numeric fields keep a text drag's start when the pointer gains focus, including short movements that egui still classifies as clicks (#1326). Any pointer movement from the press keeps the caret; only a tap without movement selects the whole value. Keyboard focus, double-click and numeric stepping remain regression-tested (`tests/numfields.rs`, `a_short_drag_released_as_a_click_keeps_its_caret` and `a_sub_threshold_movement_released_as_a_click_keeps_its_caret`). +- **Done:** Numeric fields keep a text drag's start when the pointer gains focus, including short movements that egui still classifies as clicks (#1326). A release more than 1 pt from the press keeps the caret; a tap, including one with a pixel of pen or trackpad jitter, selects the whole value. Keyboard focus, double-click and numeric stepping remain regression-tested (`tests/numfields.rs`, `a_short_drag_released_as_a_click_keeps_its_caret` and `a_sub_threshold_movement_released_as_a_click_keeps_its_caret`). - **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. - **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. From c1fee5d7a3607619cfa27614afd890e0e5bb264f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:10:30 -0800 Subject: [PATCH 58/79] Palette: picks and shortcuts skip menu rows that name user data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu items that a pick or a shortcut reads are read once. Rows that show a name that is user data (a custom toolbar under Window › Toolbars, a script under File › Scripts, a recent file, a saved selection) are now left out of them, as rows that list a plug-in already are, so a pick of Show Toolbar no longer runs with the name of a toolbar that existed when the items were read. While a menu item is dimmed and the engine gives no reason (a UI command, or an Appearance command with nothing targeted), a pick or a shortcut now opens no dialog and shows "command `` is not available right now". The message has a row in every catalog that has the engine's longer "not available right now" message. --- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus.rs | 3 +- crates/ui-egui/src/menus/labels.rs | 2 +- crates/ui-egui/src/palette.rs | 7 ++-- crates/ui-egui/src/shortcut_editor.rs | 57 +++++++++++++++++++++------ crates/ui-egui/src/shortcuts/tests.rs | 20 +++++++--- 32 files changed, 92 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97e5b335d3..adad8f49ff 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4239,6 +4239,7 @@ axis Both И двете @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` не е цвят като "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: най-много {MAX_TABLE} цвята @msg command `{0}` is not available right now: {1} команда `{0}` не е налична в момента: {1} +@msg command `{id}` is not available right now команда `{id}` не е налична в момента @msg compound paths can only contain paths съставните пътища могат да съдържат само пътища @msg compressed (RLE) bitmaps can't be read yet компресираните (RLE) битови карти все още не могат да бъдат четени @msg damaged повреден diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 75353321ff..bc01e380ad 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4627,6 +4627,7 @@ imageTrace Outline Obris @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` nije boja poput "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: najviše {MAX_TABLE} boja @msg command `{0}` is not available right now: {1} naredba `{0}` trenutno nije dostupna: {1} +@msg command `{id}` is not available right now naredba `{id}` trenutno nije dostupna @msg compound paths can only contain paths složene putanje mogu sadržavati samo putanje @msg compressed (RLE) bitmaps can't be read yet komprimirane (RLE) bitmape se još ne mogu čitati @msg damaged oštećeno diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 75e0b9ea20..1ebf8f304e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5280,6 +5280,7 @@ digits Malayalam Malaiàlam @msg column must be true or a number from 1 column ha de ser true o un nombre a partir d’1 @msg Combine Slices: the slices have no bounds Combina els talls: els talls no tenen límits @msg command `{0}` is not available right now: {1} l’ordre `{0}` no està disponible ara mateix: {1} +@msg command `{id}` is not available right now l’ordre `{id}` no està disponible ara mateix @msg compound paths can only contain paths els traçats compostos només poden contenir traçats @msg compressed (RLE) bitmaps can't be read yet encara no es poden llegir els mapes de bits comprimits (RLE) @msg control must be fillStroke, coloring, drawMode or screenMode control ha de ser fillStroke, coloring, drawMode o screenMode diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5d37ee136e..b4677f2f30 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4704,6 +4704,7 @@ imageTrace Outline Obrys @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` není barva jako "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: nejvýše {MAX_TABLE} barev @msg command `{0}` is not available right now: {1} příkaz `{0}` nyní není k dispozici: {1} +@msg command `{id}` is not available right now příkaz `{id}` nyní není k dispozici @msg compound paths can only contain paths složené cesty mohou obsahovat jen cesty @msg compressed (RLE) bitmaps can't be read yet komprimované bitmapy (RLE) zatím nelze načíst @msg damaged poškozeno diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 8167f80138..bbb1d8e2dc 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4351,6 +4351,7 @@ imageTrace Outline Kontur @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` er ikke en farve som "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: højst {MAX_TABLE} farver @msg command `{0}` is not available right now: {1} kommandoen `{0}` er ikke tilgængelig lige nu: {1} +@msg command `{id}` is not available right now kommandoen `{id}` er ikke tilgængelig lige nu @msg compound paths can only contain paths sammensatte kurver kan kun indeholde kurver @msg compressed (RLE) bitmaps can't be read yet komprimerede (RLE) bitmaps kan endnu ikke læses @msg damaged beskadiget diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index bd4bb38b43..c478fbe089 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4319,6 +4319,7 @@ theme Appearance Erscheinungsbild @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` ist keine Farbe wie "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: höchstens {MAX_TABLE} Farben @msg command `{0}` is not available right now: {1} der Befehl `{0}` ist derzeit nicht verfügbar: {1} +@msg command `{id}` is not available right now der Befehl `{id}` ist derzeit nicht verfügbar @msg compound paths can only contain paths zusammengesetzte Pfade können nur Pfade enthalten @msg compressed (RLE) bitmaps can't be read yet komprimierte Bitmaps (RLE) können noch nicht gelesen werden @msg damaged beschädigt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9860f8ad50..15e4a1d35e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4350,6 +4350,7 @@ digits Malayalam Μαλαγιάλαμ @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: το `{h}` δεν είναι έγκυρο χρώμα, όπως το "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: επιτρέπονται το πολύ {MAX_TABLE} χρώματα @msg command `{0}` is not available right now: {1} η εντολή `{0}` δεν είναι διαθέσιμη αυτήν τη στιγμή: {1} +@msg command `{id}` is not available right now η εντολή `{id}` δεν είναι διαθέσιμη αυτήν τη στιγμή @msg compound paths can only contain paths οι σύνθετες διαδρομές μπορούν να περιέχουν μόνο διαδρομές @msg compressed (RLE) bitmaps can't be read yet τα συμπιεσμένα (RLE) bitmap δεν μπορούν ακόμη να διαβαστούν @msg damaged κατεστραμμένο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 10c0c0bf87..6f5922f096 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4395,6 +4395,7 @@ axis Both Ambos @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` no es un color como "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: como máximo {MAX_TABLE} colores @msg command `{0}` is not available right now: {1} el comando `{0}` no está disponible ahora: {1} +@msg command `{id}` is not available right now el comando `{id}` no está disponible ahora @msg compound paths can only contain paths los trazados compuestos solo pueden contener trazados @msg compressed (RLE) bitmaps can't be read yet todavía no se pueden leer mapas de bits comprimidos (RLE) @msg damaged dañado diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7845235f65..6f6d7ec436 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4612,6 +4612,7 @@ imageTrace Outline Ääriviiva @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` ei ole väri, kuten "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: enintään {MAX_TABLE} väriä @msg command `{0}` is not available right now: {1} komento `{0}` ei ole nyt käytettävissä: {1} +@msg command `{id}` is not available right now komento `{id}` ei ole nyt käytettävissä @msg compound paths can only contain paths yhdistelmäpolut voivat sisältää vain polkuja @msg compressed (RLE) bitmaps can't be read yet pakattuja (RLE) bittikarttoja ei voi vielä lukea @msg damaged vaurioitunut diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5e2895f9c2..4eacb054dc 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4384,6 +4384,7 @@ axis Both Les deux @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what} : `{h}` n’est pas une couleur comme "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what} : {MAX_TABLE} couleurs au maximum @msg command `{0}` is not available right now: {1} la commande `{0}` n’est pas disponible pour le moment : {1} +@msg command `{id}` is not available right now la commande `{id}` n’est pas disponible pour le moment @msg compound paths can only contain paths les tracés transparents ne peuvent contenir que des tracés @msg compressed (RLE) bitmaps can't be read yet les images bitmap compressées (RLE) ne peuvent pas encore être lues @msg damaged endommagé diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index aca21d7eec..b8023bf0be 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6273,6 +6273,7 @@ antiAlias Sharp शार्प @msg columns {first} and {column} both name the variable `{name}` कॉलम {first} और {column} दोनों नाम चर `{name}` @msg Combine Slices: the slices have no bounds स्लाइस को मिलाएं: स्लाइस में कोई सीमा नहीं है @msg command `{0}` is not available right now: {1} आदेश `{0}` अभी उपलब्ध नहीं है: {1} +@msg command `{id}` is not available right now आदेश `{id}` अभी उपलब्ध नहीं है @msg command must be a shape.* command command: आदेश एक आकार होना चाहिए।* आदेश @msg commands[{i}]: missing `command` आदेश [{i}]: लापता `command` @msg composer must be "singleLine" or "everyLine" composer: संगीतकार "singleLine" या "everyLine" होना चाहिए। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 45d19b0ca9..e7e0f56e8d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4946,6 +4946,7 @@ imageTrace Outline Körvonal @msg column must be true or a number from 1 a column értéke true vagy 1-től kezdődő szám lehet @msg Combine Slices: the slices have no bounds Szeletek egyesítése: a szeleteknek nincsenek határai @msg command `{0}` is not available right now: {1} a parancs jelenleg nem érhető el: `{0}`: {1} +@msg command `{id}` is not available right now a parancs jelenleg nem érhető el: `{id}` @msg compound paths can only contain paths összetett útvonal csak útvonalakat tartalmazhat @msg compressed (RLE) bitmaps can't be read yet a tömörített (RLE) bitképek még nem olvashatók @msg Convert to Artboards: selection has no area Átalakítás rajztáblákká: a kijelölésnek nincs területe diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8e2b383e2d..0096d3ba9a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4351,6 +4351,7 @@ imageTrace Outline Kontur @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` bukan warna seperti "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: maksimal {MAX_TABLE} warna @msg command `{0}` is not available right now: {1} perintah `{0}` tidak tersedia saat ini: {1} +@msg command `{id}` is not available right now perintah `{id}` tidak tersedia saat ini @msg compound paths can only contain paths jalur majemuk hanya dapat berisi jalur @msg compressed (RLE) bitmaps can't be read yet bitmap terkompresi (RLE) belum dapat dibaca @msg damaged rusak diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 26e1f29651..853a7a3304 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4446,6 +4446,7 @@ axis Both Entrambi @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` non è un colore come "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: al massimo {MAX_TABLE} colori @msg command `{0}` is not available right now: {1} il comando `{0}` non è disponibile in questo momento: {1} +@msg command `{id}` is not available right now il comando `{id}` non è disponibile in questo momento @msg compound paths can only contain paths i tracciati composti possono contenere solo tracciati @msg compressed (RLE) bitmaps can't be read yet le bitmap compresse (RLE) non possono ancora essere lette @msg damaged danneggiato diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 30873028e8..2cbcf2d989 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4580,6 +4580,7 @@ swatch Bright Violet ビビッドバイオレット @msg nothing to distort 変形するものがありません @msg unknown option `{path}.{k}` `{path}.{k}` は不明なオプションです @msg command `{0}` is not available right now: {1} コマンド `{0}` は現在使用できません: {1} +@msg command `{id}` is not available right now コマンド `{id}` は現在使用できません @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}` で内部エラーが発生しました: {msg} (ドキュメントは変更されていません。このバグを報告してください) @msg invalid parameters for `{cmd}`: {msg} `{cmd}` のパラメーターが正しくありません: {msg} @msg no active document アクティブなドキュメントがありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8e9d9d049d..004c9e044e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4166,6 +4166,7 @@ font-technology Variable ცვლადი @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` ფერი არ არის, მაგალითად "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: არაუმეტეს {MAX_TABLE} ფერი @msg command `{0}` is not available right now: {1} ბრძანება `{0}` ახლა ხელმისაწვდომი არ არის: {1} +@msg command `{id}` is not available right now ბრძანება `{id}` ახლა ხელმისაწვდომი არ არის @msg compound paths can only contain paths შედგენილი ტრაექტორია მხოლოდ ტრაექტორიებს შეიძლება შეიცავდეს @msg compressed (RLE) bitmaps can't be read yet შეკუმშული (RLE) ბიტმაპების წაკითხვა ჯერ არ ხერხდება @msg damaged დაზიანებულია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f00e6cdeba..d705ad35af 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4507,6 +4507,7 @@ theme Appearance 외관 @msg nothing to distort 왜곡할 항목이 없습니다 @msg unknown option `{path}.{k}` 알 수 없는 옵션 `{path}.{k}` @msg command `{0}` is not available right now: {1} 명령 `{0}`은(는) 지금 사용할 수 없습니다: {1} +@msg command `{id}` is not available right now 명령 `{id}`은(는) 지금 사용할 수 없습니다 @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}`에서 내부 오류가 발생했습니다: {msg}(문서는 이전 상태로 유지되었습니다. 이 버그를 신고해 주세요) @msg invalid parameters for `{cmd}`: {msg} `{cmd}`의 매개변수가 올바르지 않습니다: {msg} @msg no active document 활성 문서가 없습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index bc00b76698..3efd07cd8a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4365,6 +4365,7 @@ imageTrace Outline Contour @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` is geen kleur zoals "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: maximaal {MAX_TABLE} kleuren @msg command `{0}` is not available right now: {1} opdracht `{0}` is nu niet beschikbaar: {1} +@msg command `{id}` is not available right now opdracht `{id}` is nu niet beschikbaar @msg compound paths can only contain paths samengestelde paden kunnen alleen paden bevatten @msg compressed (RLE) bitmaps can't be read yet gecomprimeerde (RLE) bitmaps kunnen nog niet gelezen worden @msg damaged beschadigd diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 23940fb80f..18eb491f68 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4527,6 +4527,7 @@ imageTrace Outline Kontur @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` nie jest kolorem w rodzaju "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: maksymalnie {MAX_TABLE} kolorów @msg command `{0}` is not available right now: {1} polecenie `{0}` jest teraz niedostępne: {1} +@msg command `{id}` is not available right now polecenie `{id}` jest teraz niedostępne @msg compound paths can only contain paths ścieżki złożone mogą zawierać tylko ścieżki @msg compressed (RLE) bitmaps can't be read yet skompresowanych bitmap (RLE) nie można jeszcze odczytać @msg damaged uszkodzony diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 32c4b4bcc0..3048c0ff24 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3935,6 +3935,7 @@ axis Both Ambos @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` não é uma cor como "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: no máximo {MAX_TABLE} cores @msg command `{0}` is not available right now: {1} o comando `{0}` não está disponível agora: {1} +@msg command `{id}` is not available right now o comando `{id}` não está disponível agora @msg compound paths can only contain paths traçados compostos só podem conter traçados @msg compressed (RLE) bitmaps can't be read yet bitmaps compactados (RLE) ainda não podem ser lidos @msg damaged danificado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 15d3c3a380..f504d64991 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4432,6 +4432,7 @@ axis Both Оба @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` не является цветом, например "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: не более {MAX_TABLE} цветов @msg command `{0}` is not available right now: {1} команда `{0}` сейчас недоступна: {1} +@msg command `{id}` is not available right now команда `{id}` сейчас недоступна @msg compound paths can only contain paths составные контуры могут содержать только контуры @msg compressed (RLE) bitmaps can't be read yet сжатые (RLE) растровые изображения пока не читаются @msg damaged повреждено diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 5ffc963fad..0da6b3a63a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4298,6 +4298,7 @@ axis Both Båda @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` är inte en färg som "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: högst {MAX_TABLE} färger @msg command `{0}` is not available right now: {1} kommandot `{0}` är inte tillgängligt just nu: {1} +@msg command `{id}` is not available right now kommandot `{id}` är inte tillgängligt just nu @msg compound paths can only contain paths sammansatta banor kan bara innehålla banor @msg compressed (RLE) bitmaps can't be read yet komprimerade (RLE) bitmappar kan inte läsas än @msg damaged skadad diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 86356d74ba..377bb4b198 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4160,6 +4160,7 @@ axis Both Her İkisi @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}`, "#ff8800" gibi bir renk değil @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: en fazla {MAX_TABLE} renginde @msg command `{0}` is not available right now: {1} `{0}` komutu şu anda kullanılamıyor: {1} +@msg command `{id}` is not available right now `{id}` komutu şu anda kullanılamıyor @msg compound paths can only contain paths bileşik yollar yalnızca yollar içerebilir @msg compressed (RLE) bitmaps can't be read yet sıkıştırılmış (RLE) bitmapler henüz okunamıyor @msg damaged hasarlı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ebba0f5c19..6541fa1f36 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4430,6 +4430,7 @@ axis Both Обидві @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` не є кольором, наприклад "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: не більше {MAX_TABLE} кольорів @msg command `{0}` is not available right now: {1} команда `{0}` зараз недоступна: {1} +@msg command `{id}` is not available right now команда `{id}` зараз недоступна @msg compound paths can only contain paths складені контури можуть містити лише контури @msg compressed (RLE) bitmaps can't be read yet стислі (RLE) растрові зображення поки не читаються @msg damaged пошкоджено diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 0ec0fd4e40..e20382bc13 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4603,6 +4603,7 @@ theme Appearance Giao diện @msg column must be true or a number from 1 column phải là true hoặc số từ 1 @msg Combine Slices: the slices have no bounds Kết hợp lát cắt: các lát cắt không có giới hạn @msg command `{0}` is not available right now: {1} lệnh `{0}` hiện không khả dụng: {1} +@msg command `{id}` is not available right now lệnh `{id}` hiện không khả dụng @msg compound paths can only contain paths đường dẫn phức chỉ có thể chứa đường dẫn @msg compressed (RLE) bitmaps can't be read yet bitmap nén (RLE) chưa thể đọc được @msg Convert to Artboards: selection has no area Chuyển thành bảng vẽ: vùng chọn không có diện tích diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9fc1cdc4e8..88ba43b1f7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4284,6 +4284,7 @@ imageTrace Outline 轮廓 @msg nothing to distort 没有可扭曲的内容 @msg unknown option `{path}.{k}` 未知选项 `{path}.{k}` @msg command `{0}` is not available right now: {1} 命令 `{0}` 当前不可用:{1} +@msg command `{id}` is not available right now 命令 `{id}` 当前不可用 @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}` 中出现内部错误:{msg} (文档保持原样;请报告此错误) @msg invalid parameters for `{cmd}`: {msg} `{cmd}` 的参数无效:{msg} @msg no active document 没有活动文档 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 685f7887fb..bc235583d9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2078,6 +2078,7 @@ file-filter Templates 範本 @msg nothing to distort 沒有可扭曲的內容 @msg unknown option `{path}.{k}` 未知的選項 `{path}.{k}` @msg command `{0}` is not available right now: {1} 指令 `{0}` 目前無法使用:{1} +@msg command `{id}` is not available right now 指令 `{id}` 目前無法使用 @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}` 發生內部錯誤:{msg} (文件維持原狀;請回報此錯誤) @msg invalid parameters for `{cmd}`: {msg} `{cmd}` 的參數無效:{msg} @msg no active document 沒有作用中的文件 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5651a6ff34..26dd765d29 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -72,8 +72,7 @@ pub(crate) use invoke::graph_dialog; pub(crate) use invoke::with_held_keys; pub use invoke::{click_target, invoke, invoke_from_system_menu}; pub(crate) use labels::lists_plugin; -#[cfg(test)] -use labels::shows_a_name; +pub(crate) use labels::shows_a_name; pub use labels::{CONTEXT_LABELS, DYNAMIC_LABELS, display_label, dynamic_label, item_shortcut, menu_strings}; pub use run::{check_params, run_ui_command}; pub(crate) use run::{normalize_panel, opt_bool, opt_index, opt_number}; diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index 408e5f077a..b8829ac0a2 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -125,7 +125,7 @@ fn command_label(app: &VectorcraftApp, id: &str, label: &str) -> Option /// label: a recent file, a saved view, a font, a user library or preset, a custom workspace, a /// plug-in, an SVG filter, a script of the Scripts folder? Such names are shown as they are, never /// translated (a workspace the user calls "Layers" stays "Layers"). -pub(super) fn shows_a_name(id: &str, label: &str) -> bool { +pub(crate) fn shows_a_name(id: &str, label: &str) -> bool { const SLOTS: [&str; 8] = [ "file.openRecent", "view.goto", diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 1a58c496b2..7b82af2e53 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -325,9 +325,10 @@ mod tests { for it in items { match it { menus::Item::Sub(_, children) => walk(children, out), - // Items that list an installed plug-in come and go as tests beside this one - // install plug-ins, and no shortcut or pick reads them. - menus::Item::Cmd(_, id, p) if !menus::lists_plugin(id, p) => out.push(it), + // Items that list an installed plug-in or show a name that is user data come and + // go as tests beside this one install plug-ins or add toolbars, and no shortcut or + // pick reads them. + menus::Item::Cmd(label, id, p) if !menus::lists_plugin(id, p) && !menus::shows_a_name(id, label) => out.push(it), _ => {} } } diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index a580b2255a..f3a201124b 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -658,13 +658,16 @@ fn menu_bar_items() -> &'static HashMap<&'static str, Vec<(&'static str, Value)> } /// The (label, params) of each item of the menus `tree`, by command id. Items that list an -/// installed plug-in ([`menus::lists_plugin`]) are left out: [`menu_bar_items`] is built once, -/// and a plug-in installed or removed later would leave it with a row the menu no longer shows. +/// installed plug-in ([`menus::lists_plugin`]) or show a name that is user data +/// ([`menus::shows_a_name`]: a custom toolbar, a script, a recent file, a saved selection) are left +/// out: [`menu_bar_items`] is built once, and such items come and go after that. fn bar_items(tree: &[(&'static str, Vec)]) -> HashMap<&'static str, Vec<(&'static str, Value)>> { fn walk(items: &[menus::Item], out: &mut HashMap<&'static str, Vec<(&'static str, Value)>>) { for it in items { match it { - menus::Item::Cmd(label, id, p) if !menus::lists_plugin(id, p) => out.entry(*id).or_default().push((*label, p.clone())), + menus::Item::Cmd(label, id, p) if !menus::lists_plugin(id, p) && !menus::shows_a_name(id, label) => { + out.entry(*id).or_default().push((*label, p.clone())) + } menus::Item::Sub(_, children) => walk(children, out), _ => {} } @@ -693,15 +696,21 @@ pub(crate) fn menu_target(id: &str) -> Option<(String, Value)> { /// What a command search pick, or a shortcut without params, of command `id` runs now: `Ok` with /// what a click on its menu bar item runs ([`menu_target`]), or `None` when no such item gives it -/// params. While that item is dimmed, no dialog opens for it: `Err` holds the status line that says -/// why the command can't run (Remove Background… with no image selected). +/// params. While that item is dimmed ([`menus::enabled`]), no dialog opens for it: `Err` holds the +/// status line ([`dimmed`]), as for Remove Background… with no image selected. pub(crate) fn menu_target_now(app: &VectorcraftApp, id: &str) -> Option> { let target = menu_target(id)?; - if menus::enabled(app, id) { - return Some(Ok(target)); - } - let why = (vectorcraft_engine::find_command(id)?.enabled)(&app.session).err()?; - Some(Err(vectorcraft_engine::EngineError::Disabled(id.to_string(), why).to_string())) + Some(if menus::enabled(app, id) { Ok(target) } else { dimmed(app, id) }) +} + +/// What [`menu_target_now`] gives for command `id` while its menu bar item is dimmed: the status +/// line, with the engine's reason when it gives one. The app's own rules ([`menus::enabled`]) dim +/// UI commands, and some commands the engine would run, without giving a reason. +fn dimmed(app: &VectorcraftApp, id: &str) -> Result<(String, Value), String> { + let Some(why) = vectorcraft_engine::find_command(id).and_then(|c| (c.enabled)(&app.session).err()) else { + return Err(format!("command `{id}` is not available right now")); + }; + Err(vectorcraft_engine::EngineError::Disabled(id.to_string(), why).to_string()) } /// Is `key` a command with no default shortcut that a key press can't run: no item of the menu @@ -2289,24 +2298,46 @@ mod tests { } /// The menu bar items a shortcut or a command search pick reads leave out items that list an - /// installed plug-in (a File › Open With importer, an Object › Plug-ins filter), which come and - /// go after the items are read; the other items stay. + /// installed plug-in (a File › Open With importer, an Object › Plug-ins filter) or show a name + /// that is user data (a custom toolbar, a script of the Scripts folder), which come and go after + /// the items are read; the other items stay. #[test] - fn menu_bar_items_leave_out_installed_plug_ins() { + fn menu_bar_items_leave_out_installed_plug_ins_and_user_data() { let tree = vec![( "File", vec![ menus::Item::Sub("Open With", vec![menus::Item::Cmd("Spins…", "file.openWith", json!({"importer": "plugin.test.spins"}))]), menus::Item::Cmd("Filter…", "plugin.dialog", json!({"id": "test.filter"})), + menus::Item::Cmd("Mine", "window.toolbar.custom", json!({"name": "Mine"})), + menus::Item::Cmd("tidy.js", crate::scripts_menu::COMMAND, json!({"path": "/scripts/tidy.js"})), menus::Item::Cmd("Clean Up…", "object.path.cleanUp", json!({"strayPoints": true})), ], )]; let items = bar_items(&tree); assert!(!items.contains_key("file.openWith"), "{items:?}"); assert!(!items.contains_key("plugin.dialog"), "{items:?}"); + assert!(!items.contains_key("window.toolbar.custom"), "{items:?}"); + assert!(!items.contains_key(crate::scripts_menu::COMMAND), "{items:?}"); assert_eq!(items.get("object.path.cleanUp"), Some(&vec![("Clean Up…", json!({"strayPoints": true}))])); } + /// While a menu item is dimmed, a pick or a shortcut gets a status line and no target: with the + /// engine's reason when it gives one, and without one when the app's own rules dim the item (an + /// Appearance command with nothing targeted, a UI command). + #[test] + fn a_dimmed_item_gives_a_status_line_with_or_without_a_reason() { + use vectorcraft_testkit::fixtures::Fixture; + let none = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + assert!(!menus::enabled(&none, "edit.findReplace")); + assert_eq!(dimmed(&none, "edit.findReplace"), Err("command `edit.findReplace` is not available right now: no document open".into())); + let empty = VectorcraftApp::new(Fixture::Empty.session(), crate::Services::default()); + for id in ["appearance.addFill", "ui.cropImage"] { + assert!(!menus::enabled(&empty, id), "{id}"); + assert!(vectorcraft_engine::find_command(id).is_none_or(|c| (c.enabled)(&empty.session).is_ok()), "{id}: the engine gives no reason"); + assert_eq!(dimmed(&empty, id), Err(format!("command `{id}` is not available right now"))); + } + } + /// Every command the editor lists, run as a key press runs it on a document with one or with /// several objects selected, reports no missing param. #[test] diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index bdc6efbc52..785fca82e2 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -413,22 +413,32 @@ fn a_single_key_shortcut_without_a_dialog_runs_at_once() { } /// A key or a chord of a command whose menu item is dimmed opens no dialog: Remove Background… and -/// Object Mosaic… with a rectangle selected change nothing and say why they can't run. +/// Object Mosaic… with a rectangle selected change nothing and say why they can't run, and so does +/// Find and Replace… with no document open. #[test] fn a_shortcut_of_a_dimmed_command_opens_no_dialog_and_says_why() { let chord = vec![ egui::Event::ModifiersChanged(cmd() | Modifiers::ALT | Modifiers::SHIFT), egui::Event::Key { key: Key::F9, physical_key: None, pressed: true, repeat: false, modifiers: cmd() | Modifiers::ALT | Modifiers::SHIFT }, ]; - for id in ["addon.image.removeBackground", "object.createObjectMosaic"] { + for id in ["addon.image.removeBackground", "object.createObjectMosaic", "edit.findReplace"] { assert!(crate::shortcut_editor::menu_target(id).is_some(), "{id} has a dialog"); for (shortcut, events) in [("F", press_f()), ("Cmd+Alt+Shift+F9", chord.clone())] { - let mut app = app_with_shortcut(id, shortcut); + let mut app = if id == "edit.findReplace" { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.screenMode", ""); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, id, shortcut); + app.ui.status.clear(); + app + } else { + app_with_shortcut(id, shortcut) + }; assert!(!crate::menus::enabled(&app, id), "{id}"); - let before = undo_len(&app); + let before = app.session.active().map(|st| st.history.undo.len()); frame(&mut app, events); crate::shortcut_editor::sync(&crate::state::UiState::default()); - assert_eq!((open_dialog(&app), undo_len(&app)), (None, before), "{id} on {shortcut}: {}", app.ui.status); + let after = app.session.active().map(|st| st.history.undo.len()); + assert_eq!((open_dialog(&app), after), (None, before), "{id} on {shortcut}: {}", app.ui.status); assert!(app.ui.status.contains("not available"), "{id} on {shortcut}: {}", app.ui.status); } } From 634cfd053d96bdbe615f450c42bfbd19f197eb64 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:13:46 -0800 Subject: [PATCH 59/79] Cutting Tools: Weed Box and Lines refuses a path past the point limit before copying it A path or compound path with more anchors than the outline points left to measure is refused before its outlines are copied and followed. --- .../engine/src/cmd/addons/cutting/weed/geometry.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/engine/src/cmd/addons/cutting/weed/geometry.rs b/crates/engine/src/cmd/addons/cutting/weed/geometry.rs index cf7df51d80..203329fe51 100644 --- a/crates/engine/src/cmd/addons/cutting/weed/geometry.rs +++ b/crates/engine/src/cmd/addons/cutting/weed/geometry.rs @@ -209,6 +209,10 @@ impl<'a> Reader<'a> { /// A path, compound path or type as its outlines. fn shape(&mut self, n: &Node) -> Result, String> { + // Each anchor is at least one point, so a path past the budget errs before it is copied. + if anchors(n) > MAX_POINTS.saturating_sub(self.points) { + return Err(too_many()); + } let Some((path, rule)) = node_path(n) else { return Ok(None) }; let polys = self.polys(path.to_bezpath())?; Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule })) @@ -269,6 +273,15 @@ fn too_many() -> String { format!("the selected art has too many outline points to measure (more than {MAX_POINTS})") } +/// The anchors of a path or compound path node, read without copying its path; 0 for type. +fn anchors(n: &Node) -> usize { + match &n.kind { + NodeKind::Path { path, .. } => path.anchor_count(), + NodeKind::Compound { children, .. } => children.iter().fold(0, |sum, c| sum.saturating_add(anchors(c))), + _ => 0, + } +} + /// A subpath through `points`, `None` for fewer than two points or one that isn't finite. fn poly(points: Vec, closed: bool) -> Option { if points.len() < 2 || points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { From be1b946b1e5a9085b8a90dead125e236ff4f8990 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:46:23 +0530 Subject: [PATCH 60/79] Docs: revision rows for the numeric-field tap tolerance (#1326) --- docs/gaps.md | 1 + docs/ui-parity.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/gaps.md b/docs/gaps.md index 5e101fe46f..e4b81f6b48 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -853,6 +853,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | G11: numeric fields keep the caret when a focus click is released more than 1 pt from its press; a tap within 1 pt still selects the whole value (#6659, follow-up to #1326); short-drag and jitter-tap regressions in `tests/numfields.rs`; estimates unchanged. | | 2026-11-10 | minor | G6: MCP catalogue (#6460): headless lists `UI_COMMANDS` (`ui: true`), eight prompts, effect vs command ids, 33 tools, commands/reference; estimate unchanged. | | 2026-11-11 | minor | G5: built-in Status Effects graphic style library (Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade, Shield Buff); Missing trimmed; estimate 7–17 → 6–16 h; MCP docs list UI Frames and Map Markers under Symbol libraries. | | 2026-11-11 | minor | G5: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem; no default 9-slice); Missing trimmed; estimate 8–18 → 7–17 h. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 44d0b19a4e..964ebdb039 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -283,7 +283,7 @@ tools, never checked side by side) isn't captured row by row. | Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370. The field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input or the pointer is pressed on a field, so a panel scrolled under a still pointer keeps scrolling past the field. This rule and its 0.5 s are VectorCraft's choices. | | Numeric spinner arrows step once on a click and keep repeating while held with the mouse | done | #6307; headless test `a_held_spinner_arrow_repeats` holds past egui's click timeout and checks release. | | Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | -| Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open | +| Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; a release more than 1 pt from the press keeps the caret, a tap within 1 pt selects all (#6659) | | Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864). A file that records no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files) opens and reverts in General units; native, SVG and DXF files and a `.ai` or EPS with ruler units keep theirs, as a [Community Expert's answer](https://community.adobe.com/questions-652/change-units-from-points-to-inches-won-t-stick-765372) describes (#5820) | | Units › East Asian Type (Q and Ha) | done | #394: `unitsAsianType` offers every unit and Q and Ha, a quarter of a millimetre each ([Scripting Guide's Measurement Units](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/): 0.709 pt per Q; [InDesign's unit notation](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html): `q`, `h`); only it lists Q and Ha. The public pages do not say which fields use it, so no field uses it yet and that waits for a source. Tests `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` | | Field precision: 3 decimals for steps; values kept as f64 | done | | @@ -511,6 +511,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Numeric fields: a focus click released more than 1 pt from its press keeps the caret; a tap within 1 pt still selects the whole value (#6659, follow-up to #1326). Scores and estimates unchanged. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | | 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | From f1f9ecfc0ffc92ff6871c1b2bd03cc7fabaa612e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:29:39 -0800 Subject: [PATCH 61/79] Cutting Tools: Weed Box and Lines leaves art in a hidden group unmeasured Selected art inside a hidden group or layer is skipped before its outlines are read, so it no longer counts toward the outline point limit. --- .../src/cmd/addons/cutting/weed/geometry.rs | 8 +++--- .../src/cmd/addons/cutting/weed/tests.rs | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/addons/cutting/weed/geometry.rs b/crates/engine/src/cmd/addons/cutting/weed/geometry.rs index 203329fe51..4642031e71 100644 --- a/crates/engine/src/cmd/addons/cutting/weed/geometry.rs +++ b/crates/engine/src/cmd/addons/cutting/weed/geometry.rs @@ -185,15 +185,15 @@ impl<'a> Reader<'a> { let mut parts = vec![]; 'ids: for path in &kept { let Some(n) = doc.node_at(path) else { continue }; + // Art in a hidden group or layer isn't shown, and isn't measured. + if (1..path.len()).any(|depth| path.get(..depth).and_then(|p| doc.node_at(p)).is_some_and(|c| !c.visible)) { + continue; + } let Some(mut region) = self.read(&[n])? else { continue }; // The containers around the object, innermost first. let mut child = n.id; for depth in (1..path.len()).rev() { let Some(container) = path.get(..depth).and_then(|p| doc.node_at(p)) else { break }; - // Art in a hidden group or layer isn't shown. - if !container.visible { - continue 'ids; - } if clips(container) && let Some(first) = container.editing_children().first().filter(|f| f.id != child) { diff --git a/crates/engine/src/cmd/addons/cutting/weed/tests.rs b/crates/engine/src/cmd/addons/cutting/weed/tests.rs index 76f5f656c6..1003ef1db3 100644 --- a/crates/engine/src/cmd/addons/cutting/weed/tests.rs +++ b/crates/engine/src/cmd/addons/cutting/weed/tests.rs @@ -451,6 +451,31 @@ fn art_in_a_hidden_group_is_left_out() { assert_eq!(weed_box(&w), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); } +#[test] +fn art_in_a_hidden_group_is_not_measured() { + // Selected art in a hidden group with more outline points than the limit doesn't count toward + // it. + let mut s = session(); + let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let mut p = BezPath::new(); + p.move_to((100.0, 100.0)); + for i in 1..=super::geometry::MAX_POINTS as u32 { + p.line_to((100.0 + f64::from(i) * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 })); + } + let dense = insert(&mut s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default())); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(g) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + select(&mut s, &[dense, shown]); + let (_, w) = weed(&mut s, json!({"margin": 10})); + assert_eq!(weed_box(&w), Some(Rect::new(290.0, 290.0, 330.0, 330.0))); +} + #[test] fn clip_outlines_crossing_too_often_are_refused() { // A clipping path whose 700 edges each run across the art, and open art whose 700 segments From 94e983943850f823c58285f7034c6d3f0e62440a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:21:32 -0800 Subject: [PATCH 62/79] Opacity masks: linked masks follow group effects through type, plug-ins and perspective Type and live objects in the art of a linked opacity mask are outlined and evaluated before a group's or layer's geometry effects run over it, so the mask moves and bends with the art it masks. Before, the effects reached only the paths in a mask's art. - With Scale Strokes & Effects on, a Transform effect scales the strokes of an image's or symbol instance's linked mask as it scales those of a path's mask. - A mask's art takes Transform copies in the form it had before the first effect, as the art it masks does. - A linked mask whose art is one image or symbol instance takes a Transform effect's move and copies with the art it masks. Under any other geometry effect, which images and symbol instances do not take, art whose masks hold one keeps its shape. - A symbol instance in perspective projects the linked masks in its art with that art. - A live-effect plug-in that paints its pieces leaves art with linked masks as it is, since a mask can't take the pieces. Docs: the docs/target-app-parity.md line moves next to the Transparency entry and lists the cases where existing documents draw differently. --- crates/effects/src/group/tests.rs | 225 +++++++++++++++++++++++++++++ crates/effects/src/reshape.rs | 150 ++++++++++++++----- crates/engine/src/cmd/xform.rs | 2 +- crates/engine/src/tests/plugins.rs | 24 +++ crates/engine/src/tests/xform.rs | 19 +++ docs/target-app-parity.md | 5 +- 6 files changed, 387 insertions(+), 38 deletions(-) diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 23f2f24c02..5c4522bb4a 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -839,6 +839,26 @@ fn the_mask_of_a_masks_art_copied_whole_takes_its_copies() { assert_eq!(own.geometric_bounds(), Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "its mask covers the copy too"); } +/// The mask of a mask's art copied whole stays in the budget while that art is copied: when the +/// budget has no room for that mask's copy, the art keeps its shape with its masks. +#[test] +fn the_mask_of_a_masks_art_copied_whole_counts_against_the_budget() { + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 1}), visible: true }; + let parts = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]); + let mask_art = masked_by(parts, white_square(6), true); + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true); + let mut art = Node::group(NodeId(10), vec![Arc::new(inner)]); + let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines; + // Room for the four squares and the copies of three of them, not for the copy of the fourth. + let budget = crate::Budget { anchors: usize::MAX / 4, lines: 7 * one }; + crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None); + let inner = &art.children().unwrap()[0]; + let square = Some(Rect::new(0.0, 0.0, 10.0, 10.0)); + assert_eq!(inner.geometric_bounds(), square, "the path keeps its shape"); + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 15.0, 10.0)), "and so does its mask"); +} + /// When a member group's linked mask can't take all the Transform copies its paths take (past the /// budget), the art keeps its shape under the effect, so the mask still covers the paths. #[test] @@ -953,6 +973,211 @@ fn a_mask_whose_art_passes_the_budget_stays_whole() { assert_eq!(*member.extras().mask.as_deref().unwrap().art, mask_art, "the mask stays whole"); } +/// Live objects in a member's linked mask are evaluated before the group's geometry effects run +/// over the mask's art, so the mask still covers the member: a Repeat in it moves and bends with +/// the member. +#[test] +fn a_groups_effects_reshape_a_live_object_in_a_members_mask() { + let mut grid = vectorcraft_doc::RepeatSpec::grid(vec![Arc::new(white_square(5))], 0.0, 0.0); + if let vectorcraft_doc::RepeatKind::Grid { rows, cols, .. } = &mut grid.kind { + (*rows, *cols) = (1, 1); + } + let repeat = Node::new(NodeId(4), NodeKind::Repeat(grid)); + let drawn: Vec = crate::expand_live(None, &repeat); + let leaf = |n: &Node| { + let mut found = None; + n.walk(&mut |c| found = found.take().or_else(|| c.path_data().cloned())); + found + }; + assert_eq!(drawn.iter().find_map(leaf), white_square(1).path_data().cloned(), "the Repeat draws one square over the member"); + for e in [reshaping_effects()[0].clone(), reshaping_effects()[4].clone()] { + let member = masked_by(white_square(1), repeat.clone(), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + let mask = &member.extras().mask.as_deref().unwrap().art; + assert!(member.path_data() != white_square(1).path_data(), "{}: the member is reshaped", e.id); + assert_eq!(leaf(mask), member.path_data().cloned(), "{}: the mask's square takes the effect with it", e.id); + } +} + +/// Type in a member's linked mask is outlined before the group's Transform effect moves the mask's +/// art with the member. +#[test] +fn a_groups_transform_moves_type_in_a_members_mask() { + if skip_without_craft_fonts() { + return; + } + let member = masked_by(white_square(1), type_h(), true); + let before = crate::reshape::outline_text(&type_h()).unwrap().geometric_bounds().unwrap(); + let mut g = Node::group(NodeId(10), vec![Arc::new(member)]); + g.appearance.effects.push(reshaping_effects()[0].clone()); + let art = evaluate_container(None, &g).unwrap(); + let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art; + let mut text = false; + mask.walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_))); + assert!(!text, "the type is outlined"); + let after = mask.geometric_bounds().unwrap(); + assert!((after.x0 - before.x0 - 30.0).abs() < 0.5 && (after.y0 - before.y0).abs() < 0.5, "{before:?} -> {after:?}"); +} + +/// The linked mask of a compound path in a group, and of a path in a layer, takes the geometry +/// effects with the art it masks. +#[test] +fn a_compound_paths_and_a_layers_members_masks_take_the_effects() { + let geometry = |n: &Node| crate::reshape::leaf_geometry(n).map(|(p, _)| p); + for e in reshaping_effects() { + let square = white_square(1); + let compound = Node::new(NodeId(1), NodeKind::Compound { children: vec![Arc::new(square.clone())], rule: FillRule::NonZero }); + let compound = masked_by(compound, white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(compound), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + assert!(geometry(member) != square.path_data().cloned(), "{}: the compound path is reshaped", e.id); + assert_eq!(geometry(&member.extras().mask.as_deref().unwrap().art), geometry(member), "{}: compound path", e.id); + + let mut layer = Node::layer(NodeId(10), "Layer", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(ch) = layer.children_mut() { + ch.extend([Arc::new(masked_by(square.clone(), white_square(4), true)), sq(2, 20.0, Color::BLACK)]); + } + layer.appearance.effects.push(e.clone()); + let art = evaluate_container(None, &layer).unwrap(); + let member = &art.children().unwrap()[0]; + assert!(member.path_data() != square.path_data(), "{}: the layer's path is reshaped", e.id); + assert_eq!(member.extras().mask.as_deref().unwrap().art.path_data(), member.path_data(), "{}: layer", e.id); + } +} + +/// A group's Transform effect moves a symbol instance member's linked mask with the instance. +#[test] +fn a_groups_transform_moves_a_symbol_instance_members_linked_mask() { + let instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(instance, white_square(4), true)), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(reshaping_effects()[0].clone()); + let art = evaluate_container(None, &g).unwrap(); + let instance = &art.children().unwrap()[0]; + let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("an instance: {instance:?}") }; + assert!((xf.translation().x - 30.0).abs() < 1e-9, "the instance moves: {xf:?}"); + let mask = instance.extras().mask.as_deref().unwrap().art.geometric_bounds().unwrap(); + assert!((mask.x0 - 30.0).abs() < 1e-9 && (mask.x1 - 40.0).abs() < 1e-9, "its mask moves with it: {mask:?}"); +} + +/// With Scale Strokes & Effects on, a group's Transform effect scales the stroke of an image +/// member's linked mask as it scales that of a path member's mask. +#[test] +fn a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike() { + let stroked = + Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0)); + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + let image = masked_by(Node::new(NodeId(1), NodeKind::Image(Box::new(im))), stroked.clone(), true); + let path = masked_by(white_square(2), stroked, true); + let mut g = Node::group(NodeId(10), vec![Arc::new(image), Arc::new(path)]); + let params = serde_json::json!({"scaleH": 200, "scaleV": 200, "strokes": true}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let width = |n: &Node| n.extras().mask.as_deref().unwrap().art.appearance.stroke().map(|s| s.width); + let ch = art.children().unwrap(); + assert_eq!(width(&ch[1]), Some(4.0), "the path's mask"); + assert_eq!(width(&ch[0]), width(&ch[1]), "the image's mask"); +} + +/// The art of a member group's mask is copied as it was before the first effect: a mask of one +/// path takes a second Transform effect's copies path by path, as the path it masks does, though +/// the first effect (with Scale Strokes & Effects) made it a group of copies. +#[test] +fn a_masks_art_is_copied_as_it_was_before_the_first_effect() { + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + for strokes in [true, false] { + let params = serde_json::json!({"moveH": 30, "copies": 2, "strokes": strokes}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + } + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), inner.geometric_bounds()); + let kinds = |n: &Node| n.children().unwrap().iter().map(|c| std::mem::discriminant(&c.kind)).collect::>(); + let paths = &inner.children().unwrap()[0]; + assert_eq!(kinds(paths).len(), 3, "the first effect's three paths"); + assert!(paths.children().unwrap().iter().all(|c| matches!(c.kind, NodeKind::Compound { .. })), "each with its copies"); + assert_eq!(kinds(mask), kinds(paths), "the mask's art takes the copies as the paths do"); +} + +/// A 10 × 10 image with id `id` at the origin. +fn image_node(id: u64) -> Node { + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + Node::new(NodeId(id), NodeKind::Image(Box::new(im))) +} + +/// A linked mask whose art is an image moves with the path it masks under a group's Transform +/// effect, and a member group's image mask takes the copies its paths take. +#[test] +fn a_groups_transform_moves_a_linked_mask_whose_art_is_an_image() { + let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(white_square(1), image_node(4), true)), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(reshaping_effects()[0].clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + assert_eq!(member.geometric_bounds(), Some(Rect::new(30.0, 0.0, 40.0, 10.0))); + assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), member.geometric_bounds()); + + let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), image_node(4), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]); + let params = serde_json::json!({"moveH": 30, "copies": 2}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let inner = &art.children().unwrap()[0]; + assert_eq!(inner.geometric_bounds(), Some(Rect::new(0.0, 0.0, 70.0, 10.0))); + let mask = &inner.extras().mask.as_deref().unwrap().art; + assert_eq!(mask.geometric_bounds(), inner.geometric_bounds(), "the image and its copies"); +} + +/// An image in a linked mask's art takes no geometry effect but Transform, so under a Warp effect +/// the art keeps its shape with its mask. +#[test] +fn art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp() { + let member = masked_by(white_square(1), Node::group(NodeId(3), vec![Arc::new(image_node(4))]), true); + let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]); + g.appearance.effects.push(reshaping_effects()[4].clone()); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + assert_eq!(member.path_data(), white_square(1).path_data(), "the path keeps its shape"); + assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0))); +} + +/// A symbol instance in perspective projects the linked masks of the objects in its art with them. +#[test] +fn perspective_projects_the_masks_in_a_symbols_art() { + let art = Node::group(NodeId(20), vec![Arc::new(masked_by(white_square(21), white_square(22), true))]); + let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let mut at = vectorcraft_doc::PerspectiveAttachment::new("left", 0.0); + at.projection = Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.01, 0.0, 1.0]); + instance.extras_mut().perspective = Some(Box::new(at)); + let out = crate::reshape::reshape(None, &instance, Some(&art)).unwrap(); + let mut masked = vec![]; + out.walk(&mut |c| masked.extend(c.extras().mask.as_deref().map(|m| (c.path_data().cloned(), m.art.path_data().cloned())))); + let [(path, mask)] = masked.as_slice() else { panic!("one masked path: {masked:?}") }; + assert!(path != &white_square(21).path_data().cloned(), "the path is projected"); + assert_eq!(mask, path, "and so is its mask"); +} + /// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit /// pitch, each crossing every bar of the other direction: n² crossings. fn crossing_bars(n: usize, effect: &str) -> Node { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 14be7ee124..feea93f18a 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -11,7 +11,7 @@ //! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives //! them ([`image_rows`]). -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use vectorcraft_doc::live::Hooks; @@ -580,7 +580,7 @@ fn as_art_opts( transform_items(&mut art, paint_doc); // Type and symbols in perspective: the art projected onto their plane. if let Some(h) = n.projection() { - project_leaves(&mut art, &h); + project_leaves(&mut art, &h, paint_doc); } // The art is a group with `n`'s id (for an image without fills or strokes that paint, a clip // group). @@ -598,9 +598,29 @@ fn as_art_opts( Some(out) } -/// Map every path under `art` (and its gradients) through the projective map `h`, curves split -/// finely enough to follow it. -fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography) { +/// Map every path under `art` (and its gradients), and every path in the art of the linked opacity +/// masks of the objects under it, through the projective map `h`, curves split finely enough to +/// follow it. The type and live objects in the art of those masks are outlined and evaluated +/// first ([`outlined_members`], with the symbols of `doc`). +fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography, doc: Option<&Document>) { + project_paths(art, h); + each_linked_mask_mut(art, false, 0, &mut |m| { + *m = outlined_mask_art(doc, m); + project_paths(m, h); + }); +} + +/// `art`, the art of an opacity mask, with type and live objects in it outlined and evaluated +/// ([`outlined_members`]), and as art that keeps its look ([`as_art`]) when it is type itself. +fn outlined_mask_art(doc: Option<&Document>, art: &Node) -> Node { + match art.kind { + NodeKind::Text(_) => as_art(doc, art, None).unwrap_or_else(|| art.clone()), + _ => outlined_members(doc, art), + } +} + +/// The paths of [`project_leaves`] outside the art of masks. +fn project_paths(art: &mut Node, h: &vectorcraft_geom::Homography) { let f = |p: vectorcraft_geom::Point| h.apply(p).unwrap_or(p); let piece = art.geometric_bounds().map_or(1.0, |b| (b.width().max(b.height()) / 64.0).max(0.25)); let project = |path: &mut PathData| *path = vectorcraft_doc::live::map_nonlinear(path, piece, f); @@ -678,7 +698,8 @@ fn hoist_copies(art: &mut Node) { /// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect /// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when /// `owner` does not hold it. The art of the linked opacity masks of the objects in `art` (not of -/// `art` itself) takes each effect as the paths it masks do ([`Step::reshape_masks`]). When a mask +/// `art` itself) takes each effect as the paths it masks do ([`Step::reshape_masks`]), with the +/// type and live objects in it outlined and evaluated first ([`outlined_members`]). When a mask /// stays as it was, or a Transform effect makes fewer copies than it asks for, art with linked /// masks keeps its shape under that effect. fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry { @@ -686,8 +707,12 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget if fx.is_empty() { return whole; } + let masked = has_linked_mask(art, false); + if masked { + each_linked_mask_mut(art, false, 0, &mut |m| *m = outlined_mask_art(doc, m)); + } let mut masks_safe = true; - each_linked_mask(art, false, 0, &mut |m| masks_safe &= leaves_safe(m)); + each_linked_mask(art, false, 0, &mut |_, m| masks_safe &= leaves_safe(m)); let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0); let style_safe = leaves_safe(art) && masks_safe && !art_stroke; let listed = fx; @@ -695,17 +720,21 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget let fx = opt_fx.as_slice(); let seed = art.id.0; let mut used = art_cost(art); - each_linked_mask(art, false, 0, &mut |m| used = used.plus(art_cost(m))); + each_linked_mask(art, false, 0, &mut |_, m| used = used.plus(art_cost(m))); let max = budget.at_least(used); - // Read before the effects run: a path's own copies don't make it art of several objects. + // Read before the effects run: a path's own copies don't make it art of several objects. The + // same holds for the art of each mask, listed by the object it masks. let copy_whole = copies_whole(art); - let masked = has_linked_mask(art, false); + let mut wholes = HashMap::new(); + each_linked_mask(art, false, 0, &mut |id, m| { + wholes.entry(id).or_insert_with(|| copies_whole(m)); + }); for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); // Where a live-effect plug-in sits, which it is listed by when it is deferred. let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e))); - let step = Step { e, params: ¶ms, bounds, seed, effect, apart, doc }; + let step = Step { e, params: ¶ms, bounds, seed, effect, apart, doc, wholes: &wholes }; // Art with linked masks keeps its shape under an effect that a mask in it could not take // as the art it masks did, so no mask is left apart from its art. let before = masked.then(|| (art.clone(), used)); @@ -726,8 +755,9 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget /// One geometry effect of an object run over its art ([`reshape_leaves_apart`]): the effect, its /// merged params, the reference box and seed of the object's art, where a deferred live-effect -/// plug-in is listed, whether each Transform copy becomes an object, and the document that the -/// swatches and patterns of painted pieces are looked up in. +/// plug-in is listed, whether each Transform copy becomes an object, the document that the +/// swatches and patterns of painted pieces are looked up in, and [`copies_whole`] of the art of +/// each linked mask before the first effect, by the id of the object it masks. struct Step<'a> { e: &'a Effect, params: &'a serde_json::Value, @@ -736,6 +766,7 @@ struct Step<'a> { effect: Option, apart: bool, doc: Option<&'a Document>, + wholes: &'a HashMap, } /// What [`Step::run`] did to a piece of art. @@ -762,12 +793,17 @@ impl Ran { impl Step<'_> { /// Run the effect over `art`, whose paths and those of the linked opacity masks in it keep to /// `max` together with what they cost so far (`used`). `copy_whole` is [`copies_whole`] of the - /// art before its first effect, or of a mask's art before this effect. With `own`, `art` is the - /// art of a mask, and its own linked mask takes the effect too. `depth` counts the masks `art` is - /// in. + /// art, or of a mask's art, before the first effect. With `own`, `art` is the art of a mask, + /// and its own linked mask takes the effect too. `depth` counts the masks `art` is in. A + /// live-effect plug-in that paints its pieces leaves a mask's art, and art that holds objects + /// with linked masks, as they are and reports the masks as stayed: a mask can't take the + /// pieces as the art it masks does. fn run(&self, art: &mut Node, max: Budget, used: &mut Budget, copy_whole: bool, own: bool, depth: usize) -> Ran { let (e, params, bounds, seed) = (self.e, self.params, self.bounds, self.seed); if let Some(plugin) = painted_plugin(e) { + if own || has_linked_mask(art, false) { + return Ran { kept: false, masks: true, short: false, whole: None }; + } let at = self.effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); for_each_leaf(art, &mut |leaf| { let Some((path, _)) = leaf_geometry(leaf) else { return }; @@ -807,6 +843,11 @@ impl Step<'_> { let options = crate::distort::TransformOptions::of(params); (m, copies, options, crate::distort::transform_affine(bounds, params, seed)) }); + // Images and symbol instances take only a Transform effect ([`transform_placed`]): a mask's + // art that holds one can't take another effect as the art it masks does. + if own && transform.is_none() && (is_placed(art) || placed_count(art) > 0) { + return Ran { kept: false, masks: true, short: false, whole: None }; + } // Art of several objects is copied whole, so the copies don't interleave member by member. // The copies of its members carry their linked masks, moved with them. if let Some((m, copies, options, _)) = transform @@ -818,10 +859,12 @@ impl Step<'_> { return Ran { kept: false, masks: false, short: false, whole: None }; } // A mask's art: its copies keep to what the rest of the art leaves of `max`, and the - // linked mask of the art itself takes them too. + // linked mask of the art itself takes them too. That mask's art stays in `used`, as + // [`copy_art`] counts only the members. let before = copy_cost(art); - let made = copy_art(art, m, copies, copy_scaling(options), max.minus(used.minus(before))); - *used = used.minus(before).plus(made); + let rest = used.minus(before).plus(art.extras().mask.as_deref().map_or(Budget { anchors: 0, lines: 0 }, |k| copy_cost(&k.art))); + let made = copy_art(art, m, copies, copy_scaling(options), max.minus(rest)); + *used = rest.plus(made); let short = art.children().is_some_and(|c| c.iter().filter(|c| !c.is_guide()).count() <= copies); let none = HashSet::new(); let masks = self.reshape_own_mask(art, &Skip { moved: &none, kept: &none }, max, used, depth); @@ -902,9 +945,20 @@ impl Step<'_> { if let Some((m, copies, options, _)) = transform && options.objects { + // A mask's art that is one image or symbol instance is placed as the only member of a + // group of it. + let root = is_placed(art); + if root { + let id = art.id; + let one = std::mem::replace(art, Node::group(id, vec![])); + *art = Node::group(id, vec![Arc::new(one)]); + } let placed = if watch { masked_placed(art, own) } else { 0 }; - transform_placed(art, m, copies); + transform_placed(art, m, copies, copy_scaling(options)); short |= watch && masked_placed(art, own) < placed.saturating_mul(copies.saturating_add(1)); + if root && let Some([one]) = art.children().map(Vec::as_slice) { + *art = (**one).clone(); + } } if outlines.iter().any(Option::is_some) { rebase_type_outlines(art, &mut outlines.into_iter()); @@ -955,10 +1009,11 @@ impl Step<'_> { return false; } let stays = holds_any(n, skip.kept); + let id = n.id; let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) else { return false }; let before = *used; let mut art = (*m.art).clone(); - let whole = copies_whole(&art); + let whole = self.wholes.get(&id).copied().unwrap_or_else(|| copies_whole(&art)); let ran = (!stays).then(|| self.run(&mut art, max, used, whole, true, depth + 1)); match ran { Some(ran) if ran.complete() => { @@ -1003,10 +1058,11 @@ fn has_linked_mask(n: &Node, own: bool) -> bool { } } -/// Run `f` on the art of the linked opacity mask of `n` (with `own`) and of each path, compound -/// path, group and layer under it, and on the masks inside that art, as [`Step::reshape_masks`] -/// reaches them: `depth` masks deep already, up to [`vectorcraft_doc::depth::MAX_DEPTH`]. -fn each_linked_mask(n: &Node, own: bool, depth: usize, f: &mut dyn FnMut(&Node)) { +/// Run `f` on the id of `n` (with `own`) and of each path, compound path, group and layer under it +/// that carries a linked opacity mask, with the art of that mask, and on the masks inside that +/// art, as [`Step::reshape_masks`] reaches them: `depth` masks deep already, up to +/// [`vectorcraft_doc::depth::MAX_DEPTH`]. +fn each_linked_mask(n: &Node, own: bool, depth: usize, f: &mut dyn FnMut(NodeId, &Node)) { if depth >= vectorcraft_doc::depth::MAX_DEPTH { return; } @@ -1014,7 +1070,7 @@ fn each_linked_mask(n: &Node, own: bool, depth: usize, f: &mut dyn FnMut(&Node)) && reshapes_mask_of(n) && let Some(m) = n.extras().mask.as_deref().filter(|m| m.linked) { - f(&m.art); + f(n.id, &m.art); each_linked_mask(&m.art, true, depth + 1, f); } if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &n.kind { @@ -1024,6 +1080,29 @@ fn each_linked_mask(n: &Node, own: bool, depth: usize, f: &mut dyn FnMut(&Node)) } } +/// [`each_linked_mask`] that changes the art of each mask: `f` runs on a mask's art before the +/// masks inside it are reached. +fn each_linked_mask_mut(n: &mut Node, own: bool, depth: usize, f: &mut dyn FnMut(&mut Node)) { + if depth >= vectorcraft_doc::depth::MAX_DEPTH || !has_linked_mask(n, own) { + return; + } + if own + && reshapes_mask_of(n) + && let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) + { + let art = Arc::make_mut(&mut m.art); + f(art); + each_linked_mask_mut(art, true, depth + 1, f); + } + if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind { + for c in children.iter_mut() { + if has_linked_mask(c, true) { + each_linked_mask_mut(Arc::make_mut(c), true, depth, f); + } + } + } +} + /// Does [`Step::reshape_masks`] reshape the linked opacity mask of `n` itself? fn has_own_linked_mask(n: &Node) -> bool { reshapes_mask_of(n) && n.extras().mask.as_ref().is_some_and(|m| m.linked) @@ -1290,7 +1369,7 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling // Each copy's own geometry effects make a different amount, as the copy and its effects scale. let mut cost = Budget::of(&moved).plus(effects_cost(©, &moved)); // Each copy carries the art of its linked masks, moved with it. - each_linked_mask(©, true, 0, &mut |m| cost = cost.plus(art_cost(m))); + each_linked_mask(©, true, 0, &mut |_, m| cost = cost.plus(art_cost(m))); if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) { break; } @@ -1822,17 +1901,20 @@ fn box_map(from: Rect, to: Rect) -> vectorcraft_geom::Affine { /// Apply the Transform effect's map `m` to every image and symbol instance under `n`, as /// [`for_each_leaf`] does to paths: the map goes into the object's placement, and `copies` more /// follow, each mapped once more than the last. Without copies the object itself takes the map, -/// as a path does. -fn transform_placed(n: &mut Node, m: Affine, copies: usize) { - transform_placed_within(n, m, copies, crate::distort::MAX_COPY_OBJECTS) +/// as a path does. The art of each one's linked opacity mask takes the map with `scaling`, as a +/// path's mask does. +fn transform_placed(n: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling) { + let mut room = crate::distort::MAX_COPY_OBJECTS.saturating_sub(placed_count(n)); + copy_placed(n, m, copies, scaling, &mut room); } /// [`transform_placed`] whose copies make at most `max` image and symbol instance nodes of the art, /// the originals included: each copy adds the object once more, and a Transform after this one /// copies the lot again, so copies stop where the result would pass `max` (#2042). +#[cfg(test)] pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, max: usize) { let mut room = max.saturating_sub(placed_count(n)); - copy_placed(n, m, copies, &mut room); + copy_placed(n, m, copies, vectorcraft_doc::Scaling::default(), &mut room); } /// An object that a Transform effect places through its transform rather than reshapes: an image @@ -1845,11 +1927,11 @@ fn is_placed(n: &Node) -> bool { /// document order, each copy taking one from `room` (what the art can still grow by), until the /// budget is full. As with a path, the copies stop before one reaches past /// [`crate::distort::within_reach`]. -fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { +fn copy_placed(n: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, room: &mut usize) { let Some(children) = n.children_mut() else { return }; for c in children.iter_mut() { if !is_placed(c) { - copy_placed(Arc::make_mut(c), m, copies, room); + copy_placed(Arc::make_mut(c), m, copies, scaling, room); continue; } let src = (**c).clone(); @@ -1872,7 +1954,7 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { if xf != Affine::IDENTITY && let Some(m) = one.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) { - Arc::make_mut(&mut m.art).transform(xf, false); + Arc::make_mut(&mut m.art).transform(xf, scaling); } one }; diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 5f505f25c5..617d9d944c 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -104,7 +104,7 @@ impl Projective { } /// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine -/// approximation at their centre. The art of a linked opacity mask is warped with the object it +/// approximation at their center. The art of a linked opacity mask is warped with the object it /// masks; an unlinked one stays where it is. fn warp_node(n: &mut Node, pr: &Projective) { n.pin_gradients(); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a1e615a08d..29f9d5a6e2 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -505,6 +505,30 @@ fn a_painted_group_effect_links_to_the_document_s_swatches() { assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the group's piece, green"); } +/// A painted effect on a group leaves the group's art as it is when a path in it has a linked +/// opacity mask, which can't take the pieces as the path does: the mask still covers the path. +#[test] +fn a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is() { + let mut s = session(); + let a = rect(&mut s, 10.0, "#ff0000"); + let m = rect(&mut s, 10.0, "#ffffff"); + run(&mut s, "select.set", json!({"ids": [a.0, m.0]})); + run(&mut s, "transparency.makeOpacityMask", json!({})); + rect(&mut s, 100.0, "#0000ff"); + run(&mut s, "select.all", json!({})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.maskedpaint", true), &out)); + run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "plugin.test.engine.maskedpaint"})); + let art = vectorcraft_render::effects::evaluate_container(Some(&s.doc().unwrap().doc), &node(&s, g)).unwrap(); + let mut masked = vec![]; + art.walk(&mut |c| masked.extend(c.extras().mask.as_ref().map(|k| (c.geometric_bounds(), k.art.geometric_bounds())))); + assert!(!masked.is_empty(), "the masked path"); + for (path, mask) in masked { + assert_eq!(path, mask, "the mask covers the path"); + } +} + /// The output of a painted effect that returns one green square linked to swatch `Leaf`. fn leaf_square() -> String { let mut leaf = solid("#00ff00"); diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index dcfda305d9..54c653e938 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -552,6 +552,25 @@ fn distort_command_warps_a_linked_opacity_mask_with_its_object() { } } +/// Free Distort of a group warps the linked opacity mask of a path inside it with the path. +#[test] +fn distort_command_warps_the_linked_mask_of_a_path_in_a_group() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let c = rect(&mut s, 150.0, 0.0, 50.0, 50.0); + s.execute("select.set", &json!({"ids": [a.0, c.0]})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let before = s.doc().unwrap().doc.node(a).unwrap().path_data().cloned(); + s.execute("select.set", &json!({"ids": [g]})).unwrap(); + s.execute("object.distort", &json!({"corners": [[10, 0], [190, 0], [200, 100], [0, 100]]})).unwrap(); + let a = s.doc().unwrap().doc.node(a).unwrap(); + assert!(a.path_data().cloned() != before, "the path is warped"); + assert_eq!(a.extras().mask.as_ref().unwrap().art.path_data(), a.path_data(), "and so is its mask"); +} + #[test] fn distort_perspective_midpoint_is_projective() { // Distort a tiny probe at the centre of a 100×100 source box into a trapezoid: with a real diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 52ef4e5f45..4ef9318c80 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -593,9 +593,7 @@ since 2026-10-01. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. -- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. -- **Linked opacity masks follow geometry effects, Free Distort and envelopes:** a linked opacity mask moves with the art it masks, and an unlinked one stays in place, as [Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html) describes for moving masked art. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, and each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too. The effects on an object do not move that object's own mask. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object in the same undo step. An envelope bends only a linked mask; before, it bent an unlinked one too. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, or the mask would get fewer Transform copies), the art and its masks keep their shape under that effect. Existing documents with a geometry effect on a group whose members have linked masks draw differently: before, those masks stayed where they were. Which masks follow the effects is VectorCraft's own rule (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`). Scores and hours unchanged. -- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. +- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. @@ -1238,6 +1236,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group. Pattern editing mode and opacity mask editing exclude each other, by a rule of VectorCraft itself: in pattern editing mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Object › Pattern › Make and Edit Pattern are off. Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set. +- **Linked opacity masks follow geometry effects, the Free Transform distort and envelopes:** when masked art moves, a linked opacity mask moves with it and an unlinked one stays in place ([Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html)). Applying that rule when art is reshaped, as below, is VectorCraft's own rule. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, with the type and live objects in a mask's art outlined first. Each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too, and with Scale Strokes & Effects on, the mask's strokes scale as a path's mask's do. The linked masks in the art of a symbol instance in perspective are projected with that art. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object, in a group too, in the same undo step. An envelope bends only a linked mask. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, the mask would get fewer Transform copies, the mask's art holds an image or symbol instance and the effect is not Transform, or the effect is a live-effect plug-in that paints its pieces), the art and its masks keep their shape under that effect. The copies of a masked path count the art of its mask against the budget. Existing documents draw differently in four cases: a geometry effect on a group whose members have linked masks moves those masks; an envelope no longer bends an unlinked mask; art with linked masks keeps its shape under an effect that its masks can't take; and a Transform effect can make fewer copies of a masked path near the budget (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::a_compound_paths_and_a_layers_members_masks_take_the_effects`, `group::tests::a_groups_effects_reshape_a_live_object_in_a_members_mask`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_groups_transform_moves_a_symbol_instance_members_linked_mask`, `group::tests::a_groups_transform_moves_a_linked_mask_whose_art_is_an_image`, `group::tests::art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp`, `group::tests::a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike`, `group::tests::perspective_projects_the_masks_in_a_symbols_art`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`, `group::tests::transform_copies_count_the_art_of_their_masks`, `group::tests::the_mask_of_a_masks_art_copied_whole_counts_against_the_budget`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `xform::distort_command_warps_the_linked_mask_of_a_path_in_a_group`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`, `plugins::a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is`). Scores and hours unchanged. - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. - **Image Trace, curve fitting tolerance:** the outline fitter keeps cutting a run while a point stays farther than the tolerance from its curve, so a run of fewer than six points or one cut 14 times no longer keeps a piece over the tolerance. Pieces already within the tolerance do not change, and noisy art can get more anchors. Tests: `logo::bezier::tests`, `logo::outline::tests`. Scores and hours unchanged. - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel. From bf9baa0b859487f56a67b569ee85d8e44fc8edaa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:36:56 -0800 Subject: [PATCH 63/79] Plug-ins: diffPath is a file key and Slice Options' url is not; the command sweep uses these keys The sweeps in command_sweep.rs kept their own list of file and folder keys (path, diffPath, folder, destinationFolder) in testkit's safe_value and again in the temp-folder test, so a command whose file key was not on the list was sent raw junk strings, and a key written as `{path | name ...}` (document.open) was never swept. - The engine exports the plug-in call rules' file keys as `cmd::key_location`, `cmd::location_in` and `cmd::location_keys`, which read every key a params description names, the keys of the commands its `...` entries name, and the schema. `diffPath` is a file key, so a plug-in call that carries it is refused; Slice Options' `url` and Export for Screens' `folders` name part of the document, so a call to object.slice.options with a top-level `url` is accepted. - testkit's `param_keys` reads the keys the engine reads, and `junk_params_for_command` and `safe_value_for_command` send every string under a file or folder key, in arrays and in objects inside them, as a location in the sweep's temp folder. Each such key also gets a list of paths and a list of `{ids, path}` objects. - Every catalog sweep, the serializability sweep included, uses them, and the temp-folder test checks every key the catalog names, at any depth. --- crates/engine/src/cmd.rs | 1 + crates/engine/src/cmd/plugin_call.rs | 87 +++++++++++- crates/engine/tests/command_sweep.rs | 189 +++++++++++++++++++++------ crates/testkit/src/strategies.rs | 99 +++++++++++--- docs/target-app-parity.md | 1 + 5 files changed, 314 insertions(+), 63 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 465025302f..a507686959 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -771,6 +771,7 @@ pub use docmenu::guide_indexes; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned}; +pub use plugin_call::{Location, key_location, location_in, location_keys}; pub(crate) use vectorcraft_doc::range::parse_range; /// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see diff --git a/crates/engine/src/cmd/plugin_call.rs b/crates/engine/src/cmd/plugin_call.rs index b151adcea2..4b8bc24e36 100644 --- a/crates/engine/src/cmd/plugin_call.rs +++ b/crates/engine/src/cmd/plugin_call.rs @@ -102,7 +102,53 @@ const FILE_WORDS: &[&str] = &[ ]; /// Param keys that name a file or a folder. -const FILE_KEYS: &[&str] = &["path", "paths", "file", "files", "folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder", "url"]; +const FILE_KEYS: &[&str] = + &["path", "paths", "file", "files", "folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder", "url", "diffPath"]; + +/// The keys in [`FILE_KEYS`] that name a folder. +const FOLDER_KEYS: &[&str] = &["folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder"]; + +/// What a param key that names a place on disk names there. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Location { + /// A file (or a list of files). + File, + /// A folder (or a list of folders). + Folder, +} + +/// What `key` names on disk when a command takes it ([`FILE_KEYS`]), or `None` when it never +/// names a file or a folder. +pub fn key_location(key: &str) -> Option { + if FOLDER_KEYS.contains(&key) { + Some(Location::Folder) + } else if FILE_KEYS.contains(&key) { + Some(Location::File) + } else { + None + } +} + +/// What `key` names on disk in command `spec`: [`key_location`], or `None` when [`NOT_FILES`] +/// names it for `spec` (it names part of the document there). +pub fn location_in(spec: &CommandSpec, key: &str) -> Option { + if NOT_FILES.contains(&(spec.id, key)) { None } else { key_location(key) } +} + +/// The param keys of `spec` that name a file or a folder, with what they name: the keys in +/// [`FILE_KEYS`] that its params description names ([`super::params::doc_keys`]), with those of the +/// commands its `…` entries name ([`super::params::takes_key`]), and its schema's params, less those +/// [`NOT_FILES`] names for it. Sorted, each key once. +pub fn location_keys(spec: &CommandSpec) -> Vec<(&'static str, Location)> { + let described = super::params::doc_keys(spec.params).map(|p| p.keys).unwrap_or_default(); + let in_schema = spec.schema.map(|s| s.params.iter().map(|p| p.name)).into_iter().flatten(); + let taken = FILE_KEYS.iter().copied().filter(|k| super::params::takes_key(spec.params, k)); + let mut keys: Vec<(&'static str, Location)> = + described.into_iter().chain(in_schema).chain(taken).filter_map(|k| Some((k, location_in(spec, k)?))).collect(); + keys.sort_unstable_by_key(|(k, _)| *k); + keys.dedup(); + keys +} /// Commands and the param key in [`FILE_KEYS`] that, in them, names part of the document (a path of /// its art, a setting, an attribute it stores or a result field), not a file. @@ -119,6 +165,8 @@ pub(crate) const NOT_FILES: &[(&str, &str)] = &[ ("attributes.set", "url"), ("svgInteractivity.info", "files"), ("addon.cleanup.rejoin", "paths"), + ("object.slice.options", "url"), + ("document.exportForScreens", "folders"), ]; /// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`], @@ -466,7 +514,8 @@ mod tests { } for (id, key) in NOT_FILES { let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") }; - assert!(names_key(spec.params, key), "{id} has no `{key}` param"); + let described = crate::cmd::params::doc_keys(spec.params).is_ok_and(|p| p.keys.contains(key)); + assert!(names_key(spec.params, key) || described, "{id} has no `{key}` param"); } let leaks: Vec<&str> = specs .iter() @@ -565,6 +614,40 @@ mod tests { read.unwrap(); } + /// [`location_keys`] names every key in [`FILE_KEYS`] that a command's params description or + /// schema names, in every way a description writes one (`path | name`, `folder? (…)`), less + /// those [`NOT_FILES`] names for it; folder keys name folders. No command a plug-in may call + /// has one. + #[test] + fn location_keys_come_from_the_command_catalog() { + let find = |id: &str| crate::cmd::find_command(id).unwrap_or_else(|| panic!("no command {id}")); + let keys = |id: &str| location_keys(find(id)); + assert!(keys("document.open").contains(&("path", Location::File)), "{:?}", keys("document.open")); + assert!(keys("document.compare").contains(&("diffPath", Location::File)), "{:?}", keys("document.compare")); + assert!(keys("addon.document.saveTimestampedCopy").contains(&("folder", Location::Folder))); + let relink = keys("links.relink"); + for k in [("path", Location::File), ("paths", Location::File), ("folder", Location::Folder)] { + assert!(relink.contains(&k), "{relink:?}"); + } + assert!(keys("action.batch").contains(&("destinationFolder", Location::Folder))); + for (id, key) in NOT_FILES { + assert!(!keys(id).iter().any(|(k, _)| k == key), "{id} `{key}` names a file"); + } + for spec in crate::cmd::command_specs() { + let found = location_keys(spec); + let described = crate::cmd::params::doc_keys(spec.params).map(|p| p.keys).unwrap_or_default(); + let in_schema = spec.schema.map(|s| s.params.iter().map(|p| p.name).collect::>()).unwrap_or_default(); + for k in described.into_iter().chain(in_schema).filter(|k| FILE_KEYS.contains(k) && !NOT_FILES.contains(&(spec.id, *k))) { + assert!(found.iter().any(|(f, _)| *f == k), "{}: `{k}` is missing from {found:?}", spec.id); + } + assert!(found.iter().all(|(k, l)| (*l == Location::Folder) == FOLDER_KEYS.contains(k)), "{}: {found:?}", spec.id); + assert!(denied(spec).is_some() || found.is_empty(), "{} names {found:?} but a plug-in may call it", spec.id); + } + assert_eq!(key_location("url"), Some(Location::File)); + assert_eq!(key_location("sourceFolder"), Some(Location::Folder)); + assert_eq!(key_location("pathData"), None); + } + #[test] fn keys_are_found_as_whole_keys() { assert!(names_key("{path: a .wasm file}", "path")); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index b41eb112f4..d607686b83 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -9,11 +9,11 @@ use vectorcraft_engine::{Session, command_specs}; use vectorcraft_testkit::catch_quiet; use vectorcraft_testkit::fixtures::Fixture; use vectorcraft_testkit::invariants::{check_all, check_session, doc_json}; -use vectorcraft_testkit::strategies::{junk_params, junk_values, param_keys, safe_value}; +use vectorcraft_testkit::strategies::{command_param_keys, junk_params_for_command, junk_values, param_keys, safe_value_for_command}; -/// Commands that replace/close the active document or touch the filesystem are fine to call, but a -/// few need their file and folder params redirected into a temp dir (`safe_value`, which -/// `junk_params` applies). +/// Commands that replace/close the active document or touch the filesystem are fine to call, but +/// their file and folder params are redirected into a temp dir (`safe_value_for_command`, which +/// `junk_params_for_command` applies, by the keys the command catalog names as files and folders). fn safe_path() -> String { vectorcraft_testkit::temp_dir("sweep").join("out.bin").to_string_lossy().to_string() } @@ -190,6 +190,33 @@ fn heavy(id: &str) -> bool { id.starts_with("document.serialize") || id.starts_with("document.export") || id == "document.open" } +/// Is `p` a params object with `k` as its only key? +fn sends_alone(p: &Value, k: &str) -> bool { + p.as_object().is_some_and(|o| o.len() == 1 && o.contains_key(k)) +} + +/// The junk params the sweeps send `c`: all of them, or for a [`heavy`] command the first 12 and, +/// for each key the command catalog names as a file or folder that no case among those sends alone +/// ([`sends_alone`]), the first case that sends a string under it alone (or any value when none +/// does). +fn junk_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec { + let mut cases = junk_params_for_command(c, path); + if heavy(c.id) { + let rest = cases.split_off(cases.len().min(12)); + for (k, _) in vectorcraft_engine::cmd::location_keys(c) { + if cases.iter().any(|p| sends_alone(p, k)) { + continue; + } + let alone = || rest.iter().filter(|p| sends_alone(p, k)); + let found = alone().find(|p| p.get(k).is_some_and(Value::is_string)).or_else(|| alone().next()); + if let Some(p) = found { + cases.push(p.clone()); + } + } + } + cases +} + #[test] fn every_command_with_junk_params() { let path = safe_path(); @@ -197,10 +224,7 @@ fn every_command_with_junk_params() { let mut calls = 0usize; for fx in Fixture::ALL { for c in command_specs() { - let mut cases = junk_params(c.params, &path); - if heavy(c.id) { - cases.truncate(12); - } + let cases = junk_cases(c, &path); for p in &cases { calls += 1; if let Some(f) = probe(fx, c.id, p) { @@ -234,13 +258,10 @@ fn tool_edit_state(s: &Session) -> ToolEditState { /// The params the tool-edit sweep sends `c`: junk params, then each documented key turned on and /// off, with a string under a file or folder key replaced by a location inside `path`'s folder. fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec { - let mut cases = junk_params(c.params, path); - if heavy(c.id) { - cases.truncate(12); - } - for k in param_keys(c.params) { + let mut cases = junk_cases(c, path); + for k in command_param_keys(c) { for v in [json!(true), json!(false), json!(1), json!("on")] { - cases.push(json!({ k.as_str(): safe_value(&k, &v, path) })); + cases.push(json!({ k.as_str(): safe_value_for_command(c, &k, &v, path) })); } } // Find Objects selects only with a filter, which one key at a time does not give it. @@ -250,35 +271,109 @@ fn tool_edit_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec) { + match v { + Value::String(s) if named && !std::path::Path::new(s).starts_with(dir) => { + out.insert(format!("{id} {at}: {v}")); + } + Value::Array(a) => { + for (i, x) in a.iter().enumerate() { + outside_locations(id, &format!("{at}[{i}]"), x, named, dir, out); + } + } + Value::Object(o) => { + for (k, x) in o { + outside_locations(id, &format!("{at}.{k}"), x, names_location(None, k), dir, out); + } + } + _ => {} + } +} + +/// Does `key` name a file or folder, in command `spec` when given: a key in +/// `vectorcraft_engine::cmd::key_location`'s list that `vectorcraft_engine::cmd::location_in` keeps +/// for `spec`. +fn names_location(spec: Option<&vectorcraft_engine::CommandSpec>, key: &str) -> bool { + match spec { + Some(spec) => vectorcraft_engine::cmd::location_in(spec, key).is_some(), + None => vectorcraft_engine::cmd::key_location(key).is_some(), + } +} + +/// Every string the sweeps send under a key that names a file or folder, in the command catalog +/// (`vectorcraft_engine::cmd::location_keys`, so a command added later is checked too) or inside +/// an object or an array at any depth, is inside the sweep's temp folder, so no run reads or +/// writes in the working directory. `every_command_with_junk_params` sends every such key of every +/// command alone in at least one case ([`junk_cases`]). #[test] fn sweep_cases_name_files_only_inside_the_temp_folder() { let path = safe_path(); let dir = vectorcraft_testkit::temp_dir("sweep"); - let keys = ["path", "diffPath", "folder", "destinationFolder"]; - let mut seen = std::collections::BTreeSet::new(); + let mut unsent = vec![]; let mut outside = std::collections::BTreeSet::new(); for c in command_specs() { - let mut cases = junk_params(c.params, &path); + let keys = vectorcraft_engine::cmd::location_keys(c); + let run = junk_cases(c, &path); + for (k, _) in &keys { + if !run.iter().any(|p| sends_alone(p, k)) { + unsent.push(format!("{} {k}", c.id)); + } + } + let mut cases = junk_params_for_command(c, &path); cases.extend(tool_edit_cases(c, &path)); + cases.extend(serializable_cases(c, &path)); for p in &cases { let Some(o) = p.as_object() else { continue }; - for (k, v) in o.iter().filter(|(k, _)| keys.contains(&k.as_str())) { - seen.insert(k.clone()); - if let Some(s) = v.as_str() - && !std::path::Path::new(s).starts_with(&dir) - { - outside.insert(format!("{} {{\"{k}\": {v}}}", c.id)); - } + for (k, v) in o { + outside_locations(c.id, k, v, names_location(Some(c), k), &dir, &mut outside); } } } - assert_eq!(seen.len(), keys.len(), "the sweeps send only {seen:?}"); + assert!(unsent.is_empty(), "every_command_with_junk_params sends no case for {unsent:?}"); let outside: Vec = outside.into_iter().collect(); assert!(outside.is_empty(), "{} cases name a location outside {}:\n{}", outside.len(), dir.display(), outside.join("\n")); } +/// The cases sweep keys the command catalog names as files and folders: `document.open`'s +/// `{path | name …}`, `addon.document.saveTimestampedCopy`'s `{folder? (…)}`, `document.compare`'s +/// `diffPath`, `file.saveAs`'s `path` after `{…as document.save}` and `links.relink`'s +/// `paths: [{ids, path}]`, each with a string in the temp folder, and a folder key a folder there. +#[test] +fn sweep_cases_send_the_file_keys_of_the_command_catalog() { + let path = safe_path(); + let dir = vectorcraft_testkit::temp_dir("sweep"); + let spec = |id: &str| command_specs().iter().find(|c| c.id == id).unwrap(); + let inside = |v: &Value| v.as_str().is_some_and(|s| std::path::Path::new(s).starts_with(&dir)); + for (id, key) in [ + ("document.open", "path"), + ("addon.document.saveTimestampedCopy", "folder"), + ("document.compare", "diffPath"), + ("text.addFontFiles", "files"), + ("file.saveAs", "path"), + ] { + let cases = junk_params_for_command(spec(id), &path); + let sent: Vec<&Value> = cases.iter().filter_map(|p| p.get(key)).collect(); + assert!(sent.iter().any(|v| inside(v) || v.as_array().is_some_and(|a| a.iter().any(inside))), "{id} sends no `{key}` in the temp folder"); + } + for (id, key) in + [("document.open", "path"), ("document.open", "name"), ("addon.document.saveTimestampedCopy", "folder"), ("links.relink", "folder")] + { + assert!(param_keys(spec(id).params).iter().any(|k| k == key), "{id} has no `{key}` key"); + } + let folder = junk_params_for_command(spec("addon.document.saveTimestampedCopy"), &path); + assert!(folder.iter().filter_map(|p| p.get("folder")?.as_str()).all(|s| s != path && std::path::Path::new(s).starts_with(&dir))); + let relink = junk_params_for_command(spec("links.relink"), &path); + let nested = relink.iter().filter_map(|p| p.get("paths")?.as_array()).flatten().filter_map(|e| e.get("path")).filter(|v| v.is_string()); + assert!(nested.clone().count() > 0 && nested.clone().all(inside), "links.relink sends no `paths[].path` in the temp folder"); + // A key that names part of the document in a command keeps its junk there. + let on_path = junk_params_for_command(spec("text.createInPath"), &path); + assert!(on_path.iter().any(|p| p.get("path") == Some(&json!("junk")))); +} + /// Every command that runs at once while the active tool computes an edit on another thread (a /// Shape Builder merge), rather than applying the edit first (`waits_for_tool_edit`), leaves the /// documents' revisions and selections, the active document and the current paint as they were, @@ -1007,28 +1102,36 @@ fn polygon_properties_junk() { assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); } +/// The params `fuzzed_calls_keep_documents_serializable` sends `c`: each key with each of the +/// first junk values, a string under a file or folder key replaced by a location inside `path`'s +/// folder. +fn serializable_cases(c: &vectorcraft_engine::CommandSpec, path: &str) -> Vec { + if heavy(c.id) || c.id.starts_with("file.") { + return vec![]; + } + let mut cases = vec![]; + for v in junk_values().iter().take(8) { + for k in command_param_keys(c) { + cases.push(json!({ k.as_str(): safe_value_for_command(c, &k, v, path) })); + } + } + cases +} + /// After a successful fuzzed call, the document still round-trips and exports. #[test] fn fuzzed_calls_keep_documents_serializable() { let path = safe_path(); let mut failures = vec![]; for c in command_specs() { - if heavy(c.id) || c.id.starts_with("file.") { - continue; - } - for v in junk_values().iter().take(8) { - for k in param_keys(c.params) { - let k = if k == "path" { continue } else { k }; - let mut s = Fixture::Multi.session(); - let p = json!({ k.clone(): v }); - let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false); - if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) { - failures.push(format!("{} {p}: {e}", c.id)); - } + for p in serializable_cases(c, &path) { + let mut s = Fixture::Multi.session(); + let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false); + if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) { + failures.push(format!("{} {p}: {e}", c.id)); } } } - let _ = path; failures.sort(); failures.dedup(); assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.iter().take(40).cloned().collect::>().join("\n")); @@ -1091,7 +1194,7 @@ fn recovery_commands_with_a_damaged_store_and_junk_params() { for fx in Fixture::ALL { for id in ["file.recovery.save", "file.recovery.list", "file.recovery.restore", "file.recovery.discard"] { let spec = command_specs().iter().find(|c| c.id == id).unwrap(); - for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) { + for p in std::iter::once(json!({})).chain(junk_params_for_command(spec, &path)) { let mut s = fx.session(); s.recovery.set_store(store.clone()); let r = catch_quiet(|| no_panic(s.execute(id, &p))); @@ -1120,7 +1223,7 @@ fn envelope_commands_with_junk_params_on_envelopes() { command_specs().iter().map(|c| c.id).filter(|id| id.starts_with("object.envelope.") || id.starts_with("object.mesh.")).collect(); for id in ids { let spec = command_specs().iter().find(|c| c.id == id).unwrap(); - for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) { + for p in std::iter::once(json!({})).chain(junk_params_for_command(spec, &path)) { let mut s = Fixture::Multi.session(); if s.execute(make, mp).is_err() { failures.push(format!("{make} failed on the Multi fixture")); @@ -1217,7 +1320,7 @@ fn text_commands_with_junk_params_on_inline_graphics() { } for id in ["text.insertInline", "text.editRange", "text.getRange", "text.setRangeStyle", "type.insert"] { let spec = command_specs().iter().find(|c| c.id == id).unwrap(); - for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) { + for p in std::iter::once(json!({})).chain(junk_params_for_command(spec, &path)) { run(id, &p); } } diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs index 10b0608001..153ede8f02 100644 --- a/crates/testkit/src/strategies.rs +++ b/crates/testkit/src/strategies.rs @@ -2,7 +2,8 @@ use proptest::prelude::*; use serde_json::{Value, json}; -use vectorcraft_engine::{Session, command_specs}; +use vectorcraft_engine::cmd::{Location, key_location, location_in, location_keys}; +use vectorcraft_engine::{CommandSpec, Session, command_specs}; use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath}; use crate::fixtures::{art_nodes, top_level_ids}; @@ -473,8 +474,11 @@ pub fn all_param_keys() -> Vec { keys.into_iter().collect() } -/// Keys of a params doc string like `{x, y, width?, radius?: pt} → {id}` (only the part before `→`). +/// Keys of a params doc string like `{x, y, width?, radius?: pt} → {id}` (only the part before `→`), +/// with every key the engine reads in it ([`vectorcraft_engine::cmd::params::doc_keys`]: `path | name`, +/// `folder? (…)`). pub fn param_keys(doc: &str) -> Vec { + let described = vectorcraft_engine::cmd::params::doc_keys(doc).map(|p| p.keys).unwrap_or_default(); let doc = doc.split('→').next().unwrap_or(""); let chars: Vec = doc.chars().collect(); let mut out = vec![]; @@ -497,55 +501,114 @@ pub fn param_keys(doc: &str) -> Vec { i += 1; } } + out.extend(described.into_iter().map(str::to_string)); out.sort(); out.dedup(); out } -/// Parameter-name keys that name a file or folder a command may write: fuzzing these with -/// arbitrary strings could write files anywhere, so callers substitute a temp path. +/// The keys of command `spec`: those of its params doc ([`param_keys`]), of its schema and its file +/// and folder keys ([`location_keys`]). +pub fn command_param_keys(spec: &CommandSpec) -> Vec { + let mut keys = param_keys(spec.params); + keys.extend(spec.schema.into_iter().flat_map(|s| s.params.iter().map(|p| p.name.to_string()))); + keys.extend(location_keys(spec).into_iter().map(|(k, _)| k.to_string())); + keys.sort(); + keys.dedup(); + keys +} + +/// Parameter-name keys that name a file or folder in some command ([`key_location`]): fuzzing these +/// with arbitrary strings could write files anywhere, so callers substitute a temp path. pub fn is_path_key(k: &str) -> bool { - matches!(k, "path" | "diffPath") || is_folder_key(k) + key_location(k).is_some() } -/// Path keys that name a folder a command creates or writes files into. +/// Path keys that name a folder a command reads, creates or writes files into ([`key_location`]): +/// `folder`, `folders`, `dir`, `directory`, `destinationFolder` and `sourceFolder`. pub fn is_folder_key(k: &str) -> bool { - matches!(k, "folder" | "destinationFolder") + key_location(k) == Some(Location::Folder) } -/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for -/// a folder key. +/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it (in +/// the same temp folder) for a folder key. pub fn safe_location(k: &str, safe_path: &str) -> String { - if is_folder_key(k) { format!("{safe_path}-folder") } else { safe_path.to_string() } + location(key_location(k).unwrap_or(Location::File), safe_path) } -/// `v`, or the temp location for `k` when `k` is a path key and `v` is a string. +/// The temp location for a key that names `l`: `safe_path` for a file, a folder next to it (in +/// the same temp folder) for a folder. +fn location(l: Location, safe_path: &str) -> String { + if l == Location::Folder { format!("{safe_path}-folder") } else { safe_path.to_string() } +} + +/// `v` with every string in it that can name a place on disk sent to the temp location: `v` +/// itself and the members of arrays in it, at any depth, when `kind` is `Some`, and in an object +/// at any depth, the strings under a path key ([`is_path_key`]). Other values keep their junk +/// shape. +fn redirect(kind: Option, v: &Value, safe_path: &str) -> Value { + match v { + Value::String(_) => kind.map_or_else(|| v.clone(), |l| json!(location(l, safe_path))), + Value::Array(a) => Value::Array(a.iter().map(|x| redirect(kind, x, safe_path)).collect()), + Value::Object(o) => Value::Object(o.iter().map(|(k, x)| (k.clone(), redirect(key_location(k), x, safe_path))).collect()), + _ => v.clone(), + } +} + +/// `v`, with its strings sent to the temp location when `k` is a path key in any command +/// ([`is_path_key`]), and the strings under path keys in objects inside it too. pub fn safe_value(k: &str, v: &Value, safe_path: &str) -> Value { - if is_path_key(k) && v.is_string() { json!(safe_location(k, safe_path)) } else { v.clone() } + redirect(key_location(k), v, safe_path) +} + +/// `v` as [`safe_value`] makes it for command `spec` ([`location_in`]): a key that names part of +/// the document in it keeps its strings. +pub fn safe_value_for_command(spec: &CommandSpec, k: &str, v: &Value, safe_path: &str) -> Value { + redirect(location_in(spec, k), v, safe_path) } /// A deterministic list of junk param objects for `doc` (the command's params doc): each -/// documented key set to each junk value, all keys junk at once, wrong top-level types. +/// documented key set to each junk value, all keys junk at once, wrong top-level types. A string +/// under a key that names a file or folder in any command ([`is_path_key`]) is a temp location. pub fn junk_params(doc: &str, safe_path: &str) -> Vec { - let keys = param_keys(doc); + junk_params_with(¶m_keys(doc), |k, v| safe_value(k, v, safe_path), key_location, safe_path) +} + +/// [`junk_params`] for command `spec`, with the keys of its schema and its file and folder keys too +/// ([`command_param_keys`]), and a string under each key that names a file or folder in it +/// ([`location_in`]) a temp location, as are the strings in an array of them and under the path +/// keys of objects in one (`paths: [{ids, path}]`), which it also sends. +pub fn junk_params_for_command(spec: &CommandSpec, safe_path: &str) -> Vec { + junk_params_with(&command_param_keys(spec), |k, v| safe_value_for_command(spec, k, v, safe_path), |k| location_in(spec, k), safe_path) +} + +/// The junk cases for `keys`: `safe(k, v)` is what is sent for junk value `v` under `k`, and +/// `kind(k)` what `k` names on disk, if anything, whose keys also get a list of locations and a +/// list of objects with a `path`. +fn junk_params_with(keys: &[String], safe: impl Fn(&str, &Value) -> Value, kind: impl Fn(&str) -> Option, safe_path: &str) -> Vec { let junk = junk_values(); let mut out = vec![Value::Null, json!([]), json!("str"), json!(42), json!({"unknownKey": 1})]; - for k in &keys { + for k in keys { for (i, v) in junk.iter().enumerate() { - let v = safe_value(k, v, safe_path); + let v = safe(k, v); // Sparse: one key at a time with every junk value, plus a few combos. let mut o = serde_json::Map::new(); o.insert(k.clone(), v.clone()); out.push(Value::Object(o)); if i % 5 == 0 { let mut all = serde_json::Map::new(); - for k2 in &keys { - let v2 = if is_path_key(k2) { json!(safe_location(k2, safe_path)) } else { v.clone() }; + for k2 in keys { + let v2 = kind(k2).map_or_else(|| v.clone(), |l| json!(location(l, safe_path))); all.insert(k2.clone(), v2); } out.push(Value::Object(all)); } } + if let Some(l) = kind(k) { + for v in [json!(["junk", "", 1]), json!([{"ids": [1], "path": "junk"}, {"path": null}])] { + out.push(json!({ k.as_str(): redirect(Some(l), &v, safe_path) })); + } + } } out } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d42a4d988b..f0f2a29f91 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1726,6 +1726,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **A grant that can't be forgotten stops counting in the whole process (G17):** when such a grant can't be removed from `grants.json`, no session in the process that uses the same `Plug-ins` folder, by any path to it, counts it for `plugin.inspect`, `plugin.install` or loading plug-ins, until `grants.json` can be written for that plug-in; another process on the folder reads `grants.json` as it is. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. +- **The command sweep sends file and folder params into its temp folder by the command catalog:** the sweeps in `crates/engine/tests/command_sweep.rs` take each command's file and folder keys from the catalog (`cmd::location_keys`, the same key list the plug-in call rules use, less the keys that name part of the document in a command), read every key a params description names (`{path | name …}`, `{folder? (…)}` included) and its schema's params, and send every string under such a key, in arrays and in objects inside them (`paths: [{ids, path}]`), as a location in the temp folder, so a command added later is swept and checked without editing the sweep. `diffPath` counts as a file key, and Slice Options' `url` and Export for Screens' `folders` do not. Regressions: `sweep_cases_name_files_only_inside_the_temp_folder` and `sweep_cases_send_the_file_keys_of_the_command_catalog` in `command_sweep`, `location_keys_come_from_the_command_catalog` in `cmd::plugin_call`. Scores and hours unchanged. - **A drag ended by a command with Real-time Drawing off:** when Undo, Redo, a filter that calls commands or a plug-in command that calls commands ends a drag whose preview Real-time Drawing off has not applied yet, the preview is applied first and kept as an undo step of its own, as releasing the drag keeps it; a preview that fails cancels the drag. The filter or plug-in command is then given the objects where the drag left them. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/src/tests/prefs.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. From 32611d8f54c165dc3200e2f21f0d0def7ba7fb3d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:51:42 -0800 Subject: [PATCH 64/79] Number fields: the rest of a wheel notch a field took no longer scrolls the panel egui spreads a wheel notch over the next frames. When a number field took a notch and the pointer then left the field, the rest of that notch scrolled the panel under the pointer, or a panel drawn before the field. - A begin-pass plugin (`WheelTailPlugin`) keeps, per viewport, how much of the notches number fields took egui has not let out yet, and takes what egui lets out of them out of each pass's scrolling before any panel scrolls. - It reads each pass's wheel events in order with egui's touch rules (start, end, cancel, and the timeout of a touch that never ends), so a turn inside a touch, or one ended in the same pass, leaves nothing owed. - A turn the other way on the panel while the rest runs out scrolls the panel by its whole size. --- crates/ui-egui/src/tests/numfields.rs | 228 ++++++++++++++++++++++++++ crates/ui-egui/src/widgets/numeric.rs | 171 ++++++++++++++++++- 2 files changed, 396 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index 45801c22ec..71beabfa23 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -1145,6 +1145,234 @@ fn a_frame_with_turns_on_and_off_a_number_field_scrolls_the_panel() { assert_eq!(value.get(), 10.0); } +/// The rest of a notch that a number field took, which egui spreads over the next frames, scrolls +/// no panel after the pointer leaves the field: neither the field's own panel nor a panel drawn +/// before it. A notch turned after that scrolls the panel under the pointer by one whole notch. +#[test] +fn the_rest_of_a_notch_a_number_field_took_scrolls_no_panel_after_the_pointer_leaves() { + for to_other_panel in [false, true] { + let mut f = Field::new(); + let (left, right, value, field) = (Cell::new(0.0), Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + ui.horizontal_top(|ui| { + let out = egui::ScrollArea::vertical().id_salt("left").max_height(120.0).show(ui, |ui| { + ui.allocate_space(vec2(100.0, 600.0)); + }); + left.set(out.state.clamped_offset().y); + let out = egui::ScrollArea::vertical().id_salt("right").max_height(120.0).show(ui, |ui| { + ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + ui.add_space(600.0); + v + }) + .inner + }); + right.set(out.state.clamped_offset().y); + out.inner + }) + .inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = if to_other_panel { Pos2::new(50.0, 60.0) } else { Pos2::new(on.x, field.get().bottom() + 40.0) }; + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + f.frame(vec![Event::PointerMoved(off)], &draw); + for _ in 0..20 { + f.frame(vec![], &draw); + } + assert_eq!((left.get(), right.get(), value.get()), (0.0, 0.0, 9.0), "pointer moved to the other panel: {to_other_panel}"); + assert_eq!(f.frame(vec![notch_down()], &draw), None); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + let (scrolled, still) = if to_other_panel { (left.get(), right.get()) } else { (right.get(), left.get()) }; + assert!((scrolled - line).abs() < 0.01 && still == 0.0, "other panel {to_other_panel}: {scrolled} {still}"); + assert_eq!(value.get(), 9.0); + } +} + +/// A wheel touch that starts and never ends (some mouse wheels send no end) stops counting as a +/// touch once no wheel event comes for a while, as egui stops it, so the rest of a notch a number +/// field takes after that still scrolls no panel after the pointer leaves. +#[test] +fn a_wheel_touch_with_no_end_does_not_let_the_rest_of_a_later_notch_scroll_the_panel() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + ui.add_space(600.0); + v + }) + .inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = Pos2::new(on.x, field.get().bottom() + 40.0); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + for _ in 0..20 { + f.frame(vec![], &draw); + } + let start = Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::Vec2::ZERO, + phase: egui::TouchPhase::Start, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + f.frame(vec![start], &draw); + for _ in 0..10 { + f.frame(vec![], &draw); + } + assert_eq!(f.frame(vec![notch_down()], &draw), Some(8.0)); + f.frame(vec![Event::PointerMoved(off)], &draw); + for _ in 0..20 { + f.frame(vec![], &draw); + } + assert_eq!((offset.get(), value.get()), (0.0, 8.0)); +} + +/// A whole wheel touch (start, turn and end) in one pass that a number field takes leaves nothing +/// for egui to let out later, so the next notch on the panel scrolls it by one whole notch. +#[test] +fn a_whole_touch_in_one_pass_on_a_number_field_leaves_the_next_panel_notch_whole() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + let v = ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + v + }); + ui.add_space(600.0); + v.inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = Pos2::new(on.x, field.get().bottom() + 40.0); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + for _ in 0..20 { + f.frame(vec![], &draw); + } + let touch = |phase| Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: if phase == egui::TouchPhase::Move { vec2(0.0, -1.0) } else { egui::Vec2::ZERO }, + phase, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + let events = vec![touch(egui::TouchPhase::Start), touch(egui::TouchPhase::Move), touch(egui::TouchPhase::End)]; + assert_eq!(f.frame(events, &draw), Some(8.0)); + f.frame(vec![Event::PointerMoved(off), notch_down()], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + assert!((offset.get() - line).abs() < 0.01, "{} {line}", offset.get()); + assert_eq!(value.get(), 8.0); +} + +/// A turn and the end of the wheel stream in one pass that a number field takes leave nothing for +/// egui to let out later, so the next notch on the panel scrolls it by one whole notch. +#[test] +fn a_turn_ended_in_its_own_pass_on_a_number_field_leaves_the_next_panel_notch_whole() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + let v = ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + v + }); + ui.add_space(600.0); + v.inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = Pos2::new(on.x, field.get().bottom() + 40.0); + let end = Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::Vec2::ZERO, + phase: egui::TouchPhase::End, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down(), end], &draw), Some(9.0)); + f.frame(vec![Event::PointerMoved(off), notch_down()], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + assert!((offset.get() - line).abs() < 0.01, "{} {line}", offset.get()); + assert_eq!(value.get(), 9.0); +} + +/// A small turn the other way on the panel while the rest of a notch a number field took is still +/// being let out scrolls the panel by the whole turn. +#[test] +fn a_turn_the_other_way_while_a_number_field_notch_runs_out_scrolls_the_panel_by_the_whole_turn() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + ui.add_space(150.0); + let v = ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + v + }); + ui.add_space(600.0); + v.inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + let above = |field: Rect| Pos2::new(field.center().x, field.top() - 30.0); + f.frame(vec![Event::PointerMoved(Pos2::new(field.get().center().x, 10.0)), notch_down(), notch_down()], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let scrolled = offset.get(); + assert!((scrolled - 2.0 * line).abs() < 0.01, "{scrolled}"); + f.frame(vec![Event::PointerMoved(field.get().center())], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(9.0)); + let up = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 10.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + f.frame(vec![Event::PointerMoved(above(field.get())), up], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + assert!((offset.get() - (scrolled - 10.0)).abs() < 0.01, "{} {scrolled}", offset.get()); + assert_eq!(value.get(), 9.0); +} + /// A number field that missed a pass (its panel was hidden) waits for the pointer to move onto it /// again before it takes the wheel, with or without another number field drawn meanwhile. #[test] diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index bf25ce58bd..3eec21b928 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -302,13 +302,29 @@ fn step_value( } // Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is // left of a notch carries over to the next turn. - let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel")); + let (options, notch_id) = (ui.ctx().options(|o| o.input_options), resp.id.with("wheel")); + let line = options.line_scroll_speed; let mut notches: f32 = if wheel { ui.data(|d| d.get_temp(notch_id)).unwrap_or(0.0) } else { 0.0 }; + let tail_key = wheel_tail_id(ui.ctx().viewport_id()); + let mut tail: WheelTail = if wheel { ui.data(|d| d.get_temp(tail_key)).unwrap_or_default() } else { WheelTail::default() }; + let pass = ui.ctx().cumulative_pass_nr(); + let stored: Option<(u64, Vec<(Event, egui::Vec2)>)> = if wheel { ui.data(|d| d.get_temp(wheel_turns_id(tail_key))) } else { None }; + let mut measured = stored.filter(|(at, _)| *at == pass).map(|(_, turns)| turns); + let mut turns = Vec::new(); + let mut took = egui::Vec2::ZERO; // Each press and each turn of whole notches in the order they came: its modifiers and size from // [`STEPS`], and how many steps up (down when negative). let mut steps: Vec<(egui::Modifiers, f64, f64)> = Vec::new(); let mut taken = taken; ui.input_mut(|i| { + let (page, dt) = (i.viewport_rect().height(), i.stable_dt); + // In a pass [`WheelTailPlugin`] did not run in (it is added in this one), the field + // measures the pass's turns itself. + turns = match measured.take() { + Some(turns) => turns, + None if wheel => wheel_turns(&mut tail, &i.events, &options, page, i.time), + None => Vec::new(), + }; i.events.retain(|e| { // Each taken event is matched once by its value, so wheel events that other code adds // or removes before the field runs do not shift which events the field takes. @@ -318,7 +334,15 @@ fn step_value( let n = match e { Event::Key { key: Key::ArrowUp, pressed: true, .. } if focused => 1.0, Event::Key { key: Key::ArrowDown, pressed: true, .. } if focused => -1.0, - Event::MouseWheel { unit, delta, .. } if mine => { + Event::MouseWheel { unit, delta, phase, .. } if mine => { + if *phase == egui::TouchPhase::Move { + // Each turn of the pass was measured in the order egui read them; a turn + // other code added after that falls back on the touch state. + took += match turns.iter().position(|(t, _)| t == e) { + Some(at) => turns.remove(at).1, + None => unsmoothed_points(&options, page, tail.touch, *unit, *delta, *modifiers), + }; + } // Shift may turn the wheel sideways (the Mac does). let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; notches += match unit { @@ -340,9 +364,16 @@ fn step_value( // The rest of a turn egui spreads over the next frames scrolls nothing either. i.smooth_scroll_delta = egui::Vec2::ZERO; } + // What egui has not let out of the turns taken here yet stays out of scrolling after the + // pointer leaves the field ([`WheelTailPlugin`]). + tail.owed += took - tail_release(took, dt); }); if wheel { - ui.data_mut(|d| d.insert_temp(notch_id, notches)); + ui.data_mut(|d| { + d.insert_temp(notch_id, notches); + d.insert_temp(tail_key, tail); + d.insert_temp(wheel_turns_id(tail_key), (pass, turns)); + }); } if steps.is_empty() { return None; @@ -442,6 +473,9 @@ fn number_wheel_turns(ui: &Ui, resp: &Response) -> (Vec, bool) { Some((state.walk, state.before.clone())) }); let walked = fresh.map(|(walk, before)| { + if ctx.with_plugin(|_: &mut WheelTailPlugin| ()).is_none() { + ctx.add_plugin(WheelTailPlugin); + } let (events, now) = ui.input(|i| (i.events.clone(), i.time)); walk_wheel(ctx, walk, &events, &before, now, pass) }); @@ -557,6 +591,137 @@ fn walk_wheel( (walk, taken) } +/// The wheel turns that number fields took in one viewport and egui has not let out yet (points), +/// whether a trackpad touch is running there, and the time and source of the last wheel event. +#[derive(Clone, Copy, Default)] +struct WheelTail { + owed: egui::Vec2, + touch: bool, + last: f64, + trackpad: bool, +} + +impl WheelTail { + /// Ends a touch that egui ends at `time` because no wheel event came for a while (its + /// `WheelState::is_scroll_action_over`): 1 s for a trackpad, 0.15 s for any other source. + fn expire_touch(&mut self, time: f64) { + let timeout = if self.trackpad { 1.0 } else { 0.150 }; + if self.touch && timeout < time - self.last { + self.touch = false; + } + } +} + +fn wheel_tail_id(viewport: egui::ViewportId) -> egui::Id { + egui::Id::unique("number-field-wheel-tail").with(viewport) +} + +/// Where the wheel turns of a pass are kept with their [`unsmoothed_points`] and the pass number, +/// from [`wheel_turns`]. +fn wheel_turns_id(tail: egui::Id) -> egui::Id { + tail.with("turns") +} + +/// Reads the wheel events of a pass at `time` in order, as egui does, into `tail`'s touch state +/// (a start begins a touch; an end or cancel ends it and drops what is owed; a touch with no event +/// for a while ends), and returns each turn with its [`unsmoothed_points`] at that point. +fn wheel_turns(tail: &mut WheelTail, events: &[egui::Event], options: &egui::InputOptions, page: f32, time: f64) -> Vec<(egui::Event, egui::Vec2)> { + let mut turns = Vec::new(); + for event in events { + if let egui::Event::MouseWheel { unit, delta, phase, modifiers, source } = event { + tail.expire_touch(time); + tail.last = time; + tail.trackpad = *source == egui::MouseWheelSource::Trackpad; + match phase { + egui::TouchPhase::Start => tail.touch = true, + egui::TouchPhase::End | egui::TouchPhase::Cancel => { + tail.touch = false; + tail.owed = egui::Vec2::ZERO; + // egui drops what the pass's earlier turns left as well. + turns.iter_mut().for_each(|(_, points)| *points = egui::Vec2::ZERO); + } + egui::TouchPhase::Move => turns.push((event.clone(), unsmoothed_points(options, page, tail.touch, *unit, *delta, *modifiers))), + } + } + } + tail.expire_touch(time); + turns +} + +/// The points of wheel turn `delta` in `unit` that egui spreads over the next passes instead of +/// scrolling at once (its `WheelState::on_wheel_event`): a turn sideways with the horizontal +/// scroll modifier, up and down with the vertical one, and nothing for a zoom turn, a touch or a +/// turn under 8 points. `page` is the viewport's height. `modifiers` are the turn's own: egui keeps a +/// touch's modifiers only inside the touch, where nothing is spread out. +fn unsmoothed_points( + options: &egui::InputOptions, + page: f32, + touch: bool, + unit: egui::MouseWheelUnit, + delta: egui::Vec2, + modifiers: egui::Modifiers, +) -> egui::Vec2 { + let mut d = match unit { + egui::MouseWheelUnit::Point => delta, + egui::MouseWheelUnit::Line => options.line_scroll_speed * delta, + egui::MouseWheelUnit::Page => page * delta, + }; + let (sideways, upright) = (modifiers.matches_any(options.horizontal_scroll_modifier), modifiers.matches_any(options.vertical_scroll_modifier)); + if sideways && !upright { + d = egui::vec2(d.x + d.y, 0.0); + } + if upright && !sideways { + d = egui::vec2(0.0, d.x + d.y); + } + let smooth = touch || unit == egui::MouseWheelUnit::Point && d.length() < 8.0; + if smooth || modifiers.matches_any(options.zoom_modifier) { egui::Vec2::ZERO } else { d } +} + +/// What egui lets out in a pass `dt` seconds long of `owed` points of wheel input it has not let +/// out yet (its `WheelState::after_events`): all of an axis under a point, otherwise the share that +/// lets out 90% in 0.1 s. +fn tail_release(owed: egui::Vec2, dt: f32) -> egui::Vec2 { + let t = egui::emath::exponential_smooth_factor(0.90, 0.1, dt.min(0.1)); + let axis = |v: f32| if v.abs() < 1.0 { v } else { t * v }; + egui::vec2(axis(owed.x), axis(owed.y)) +} + +/// Takes out of each pass's scrolling what egui lets out of the wheel turns number fields took +/// ([`WheelTail`]), before any panel scrolls, so the rest of a turn a field took scrolls nothing +/// after the pointer leaves the field. A turn's end or cancel drops what is left. It also measures +/// each wheel turn of the pass with the touch state egui had at that turn, for [`step_value`]. +#[derive(Default)] +struct WheelTailPlugin; + +impl egui::Plugin for WheelTailPlugin { + fn debug_name(&self) -> &'static str { + "number field wheel tail" + } + + fn on_begin_pass(&mut self, ui: &mut Ui) { + let key = wheel_tail_id(ui.ctx().viewport_id()); + let (options, pass) = (ui.ctx().options(|o| o.input_options), ui.ctx().cumulative_pass_nr()); + let mut tail = ui.data(|d| d.get_temp::(key)).unwrap_or_default(); + let mut turns = Vec::new(); + ui.input_mut(|i| { + turns = wheel_turns(&mut tail, &i.events, &options, i.viewport_rect().height(), i.time); + if tail.owed == egui::Vec2::ZERO { + return; + } + let release = tail_release(tail.owed, i.stable_dt); + tail.owed -= release; + // A zoom turn takes what egui lets out, so nothing of it reaches scrolling. + if i.smooth_scroll_delta != egui::Vec2::ZERO || i.zoom_delta() == 1.0 { + i.smooth_scroll_delta -= release; + } + }); + ui.data_mut(|d| { + d.insert_temp(key, tail); + d.insert_temp(wheel_turns_id(key), (pass, turns)); + }); + } +} + /// A [`num_field`] for a width or height (Transform panel, Properties panel, Control bar): Ctrl/Cmd+Enter /// also commits it and ends its editing, as Enter does, and [`enter_modifiers`] reports Command. A /// percentage typed alone (`50%`) is that share of the size shown, as in Illustrator (#2401). From f6a1ec101992fedde66796cd50a3513d54526348 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 03:50:12 -0800 Subject: [PATCH 65/79] .ai save: the editing copy keeps brushes and brushed paths The editing copy of a PDF-compatible .ai now holds the Brushes panel's Calligraphic, Scatter and Art brushes in the panel's order, with the art of Scatter and Art brushes. A path or compound path with a Calligraphic brush, or with an Art brush in Tints whose art bends along the path, keeps the brush on its stroke. Other brushed strokes are still written as their brush's art. The writer reads each brush back with the reader's code before writing it. Brushes that would not come back with their settings, brushes past the reader's limits and every brush on a large canvas are left out with a warning. A brush that has the name of an earlier brush that was left out is written, and no path is written with it on its stroke. An object linked to a graphic style that is left out at the object limit now also raises the warning about links left out. Brushes and their options: https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html Docs: docs/file-format-parity.md. --- crates/engine/tests/import_fuzz.rs | 77 ++ crates/eps/src/ai_editing.rs | 22 +- crates/eps/src/ai_editing/tests.rs | 794 +++++++++++++++++++- crates/eps/src/ai_editing/writer.rs | 18 +- crates/eps/src/ai_editing/writer/brushes.rs | 558 ++++++++++++++ crates/eps/src/ai_editing/writer/styles.rs | 54 +- crates/eps/src/import.rs | 4 +- crates/eps/src/import/ai.rs | 1 + crates/eps/src/import/ai/brush.rs | 124 +++ crates/eps/src/import/ai/read/ops.rs | 23 +- docs/file-format-parity.md | 12 +- 11 files changed, 1659 insertions(+), 28 deletions(-) create mode 100644 crates/eps/src/ai_editing/writer/brushes.rs diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index ac98db8e9c..4542496fc5 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4330,6 +4330,17 @@ proptest! { survive("mutated .ai art styles", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; survive("mutated .ai art styles, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; } + + /// A `.ai` whose brushes, as the writer writes them, are damaged: the brush art with its frame, + /// the brush manager's order, the brushes' parameters, a brushed path's style and its object, cut + /// short or edited. + #[test] + fn ai_written_brushes_never_panic(cut in 0usize..BRUSHES_FUZZ_CHARS, edits in prop::collection::vec((0usize..BRUSHES_FUZZ_CHARS, prop::sample::select(vec!['0', '1', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U', 'n', 'X', 'W'])), 0..12)) { + let text = mutate_text(brushes_text(), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + survive("mutated .ai brushes", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + survive("mutated .ai brushes, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + } } /// The range of [`ai_written_styles_never_panic`]'s cuts and edits: past the end of [`styles_text`]. @@ -4442,6 +4453,72 @@ fn the_symbol_library_fuzz_data_opens_with_its_symbols() { assert_eq!(names, ["S2", "S1"], "{warnings:?}"); } +/// The range of [`ai_written_brushes_never_panic`]'s cuts and edits: past the end of [`brushes_text`]. +const BRUSHES_FUZZ_CHARS: usize = 48_000; + +/// The editing data VectorCraft writes for a document whose brushes are a Calligraphic brush, a +/// Scatter brush in Tints and an Art brush in Tints (each art a wide block over a narrow one), with a +/// path drawn with the Calligraphic brush and one with the Art brush. +fn brushes_text() -> &'static str { + static TEXT: std::sync::OnceLock = std::sync::OnceLock::new(); + TEXT.get_or_init(|| { + use std::sync::Arc; + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{Appearance, Node, NodeId}; + use vectorcraft_geom::{Rect, shapes}; + let block = |id: u64, r: Rect| { + Arc::new(Node::path(NodeId(id), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0))) + }; + let art = || Node::group(NodeId(1), vec![block(2, Rect::new(0.0, 0.0, 40.0, 4.0)), block(3, Rect::new(0.0, 4.0, 10.0, 8.0))]); + let nib = Calligraphic { + angle: 30.0, + roundness: 40.0, + size: 6.0, + variation: [20.0, 10.0, 2.0], + modes: Some([Variation::Rotation, Variation::Fixed, Variation::Pressure]), + }; + let lib = [ + Brush { name: "Nib".into(), kind: BrushKind::Calligraphic(nib) }, + Brush { name: "Spray".into(), kind: BrushKind::Scatter(Scatter { art: art(), colorization: Colorization::Tints, ..Scatter::default() }) }, + Brush { name: "Ribbon".into(), kind: BrushKind::Art(ArtBrush { art: art(), colorization: Colorization::Tints, ..ArtBrush::default() }) }, + ]; + let mut doc = Document::new(200.0, 100.0); + vectorcraft_brush::store(&mut doc, &lib); + let layer = doc.layers[0].id; + for (brush, y) in [("Nib", 30.0), ("Ribbon", 70.0)] { + let mut line = Node::path( + doc.alloc_id(), + shapes::line((10.0, y).into(), (190.0, y).into()), + Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0), + ); + line.appearance.stroke_mut().unwrap().brush = Some(brush.into()); + doc.insert(Some(layer), usize::MAX, line).unwrap(); + } + let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap(); + String::from_utf8(out.program).unwrap() + }) +} + +/// The data [`ai_written_brushes_never_panic`] damages, as it is, opens with its brushes and its +/// brushed paths live, and the property's cuts and edits reach all of it. +#[test] +fn the_written_brushes_fuzz_data_opens_live() { + let text = brushes_text(); + assert!(text.chars().count() < BRUSHES_FUZZ_CHARS, "{}", text.chars().count()); + for mark in ["%AI8_BeginBrushPattern", "%AI8_BeginPluginObject", "%AI9_BeginArtStyles", "/GObjRef :", "%AI5_BeginLayer", "\n1 (Anon"] { + assert!(text.replace("\r\n", "\n").contains(mark), "{mark}"); + } + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat(); + let (d, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); + assert!(layered, "{warnings:?}"); + let names: Vec = vectorcraft_brush::library(&d).into_iter().map(|b| b.name).collect(); + assert_eq!(names, ["Nib", "Spray", "Ribbon"]); + let art = d.layers[0].children().unwrap(); + let brushes: Vec> = art.iter().map(|n| n.appearance.stroke().and_then(|s| s.brush.as_deref())).collect(); + assert_eq!(brushes, [Some("Nib"), Some("Ribbon")]); +} + /// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the /// symbol list and a layer of instances, as the format writes them. fn symbols_text() -> String { diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 3ca8a66b5f..6c35eb7a01 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -133,6 +133,16 @@ //! A fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that //! fills by the even-odd rule is written as its drawn look. A style is written only when the //! reader reads it back as the object's appearance (or the graphic style's). +//! - Brushes, as the reader reads them (`brush.rs`, `style.rs`): the Brushes panel's brushes as +//! plug-in objects in a non-printing block after the symbols (`%AI8_BeginPluginObject`, the +//! plug-in's name, the brush's name, its parameters in strings that `-` continues and `.` ends), +//! after the brush manager's order of them; the art of Scatter and Art brushes in a non-printing +//! block before the symbols (`%AI8_BeginBrushPattern`, its name, a group of an unpainted frame at +//! the art's bounds and the art, or the art alone when it has no width or height), when the +//! document has brushes of its own or an object is written with one. +//! A path or compound path with a brush the reader reads live is written with an art style whose +//! stroke is the brush's filter (`writer::brushes`). A brush is written only when the reader reads +//! it back with its settings. //! //! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the @@ -168,9 +178,10 @@ //! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on //! layers and opacity masks nested too deep, and, on objects written without an art style, live //! effects other than geometry effects left out; graphic styles the reader wouldn't read, links to -//! graphic styles on objects not written with them, and every art style on a large canvas, are left -//! out; art past [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads), -//! counted as the reader counts them, is left out. +//! graphic styles on objects not written with them, brushes the reader wouldn't read back, and every +//! art style and brush on a large canvas, are left out; art past +//! [`crate::import::MAX_EDITING_OBJECTS`] objects in all (the most the reader reads), counted as the +//! reader counts them, is left out. //! //! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with //! the version numbers of the newest files users sent ([`private_data`]). @@ -288,6 +299,7 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re let mut w = Writer::new(doc, to_art, origin); // On a large canvas the layers' symbol instances are written as their art. w.symbols_live = scale == 1.0; + w.brushes_live = scale == 1.0; w.symbol_doc = symbol_doc; w.max_objects = max_objects; w.reserve_swatch_gradients(); @@ -305,6 +317,8 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re } // The symbols no instance uses too; their art adds definitions. w.symbols(); + // The brushes, once the layers have said whether an object is written with one. + let (brush_art, brush_objects) = w.brush_blocks(); // The palette first: its gradient and pattern swatches add definitions. let mut palette = String::new(); w.palette(&mut palette); @@ -323,7 +337,9 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re let mut s = header.clone(); s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n"); w.definitions(&mut s); + s.push_str(&brush_art); w.symbol_definitions(&mut s); + s.push_str(&brush_objects); w.art_styles(&mut s); s.push_str(&palette); w.art_style_list(&mut s); diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 1ab878fc62..ef63dcda4d 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -747,6 +747,7 @@ fn art_the_slice_does_not_cover_is_written_as_plain_paths() { assert_eq!(layer[2].visual_bounds().map(|b| (r3(b.x0), r3(b.y0), r3(b.x1), r3(b.y1))), Some((r3(100.0), r3(10.0), r3(120.0), r3(20.0)))); } +/// A brushed stroke the reader reads as its drawn look (a Scatter brush's) is written as its brush's art. #[test] fn brushed_strokes_are_written_as_their_brush_art() { let mut doc = Document::new(200.0, 100.0); @@ -756,11 +757,11 @@ fn brushed_strokes_are_written_as_their_brush_art() { shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()), Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 4.0), ); - line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + line.appearance.stroke_mut().unwrap().brush = Some("Dots".into()); doc.insert(Some(layer), usize::MAX, line).unwrap(); let pieces = vectorcraft_brush::stroke_pieces( &doc, - &vectorcraft_brush::find(&doc, "3 pt. Round").unwrap(), + &vectorcraft_brush::find(&doc, "Dots").unwrap(), &shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()).to_bezpath(), doc.layers[0].children().unwrap()[0].appearance.stroke().unwrap(), ); @@ -895,6 +896,7 @@ fn counted() -> Vec<(&'static str, Document)> { ("styles", styles_document()), ("painted clips", painted_clips()), ("blend", blend_doc()), + ("brushes", brushes_document()), ] } @@ -6911,6 +6913,28 @@ fn a_link_to_a_style_that_moves_the_outline_is_kept_where_the_reader_reads_it() assert!(n.extras().mask.is_some(), "{n:?}"); } +/// An object linked to a graphic style that doesn't fit within the object limit is left out with +/// the limit's warning and the warning about links left out. +#[test] +fn a_linked_object_past_the_object_limit_warns_that_its_link_is_left_out() { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }; + let mut g = vectorcraft_doc::GraphicStyle::new("Shadowed", ap.clone()); + g.id = 9; + doc.graphic_styles.push(g); + // Room for everything but the object. + let room = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + n.extras_mut().graphic_style = Some(9); + put(&mut doc, n); + let all = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT || w == writer::STYLE_LINKS), "{:?}", all.warnings); + let out = export_within(&doc, &opts(), room).unwrap(); + for w in [writer::OBJECT_LIMIT, writer::STYLE_LINKS] { + assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings); + } +} + /// Twist and Transform on an object that isn't linked to a graphic style are not written as a style /// of its own: the object reads back without live effects. #[test] @@ -7258,6 +7282,772 @@ fn a_large_canvas_writes_every_object_as_its_look() { assert!((p.x - 10.0).abs() < 1e-2 && (p.y - 10.0).abs() < 1e-2, "{p:?}"); } +// ---------- brushes ---------- + +/// Brush art: a wide block with a narrow one below it (y down), filled black. +fn brush_art_node() -> Node { + let block = + |id: u64, r: Rect| Arc::new(Node::path(vectorcraft_doc::NodeId(id), shapes::rectangle(r), Appearance::basic(black_fill(), Paint::None, 0.0))); + Node::group(vectorcraft_doc::NodeId(1), vec![block(2, Rect::new(0.0, 0.0, 40.0, 4.0)), block(3, Rect::new(0.0, 4.0, 10.0, 8.0))]) +} + +/// A Calligraphic brush varying with the barrel's rotation and the pen's pressure, a Scatter brush in +/// Tints, an Art brush in Tints and one that keeps its art's colors. +fn brush_library() -> Vec { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; + let art = |colorization| BrushKind::Art(ArtBrush { art: brush_art_node(), colorization, ..ArtBrush::default() }); + vec![ + Brush { + name: "Nib".into(), + kind: BrushKind::Calligraphic(Calligraphic { + angle: 30.0, + roundness: 40.0, + size: 6.0, + variation: [20.0, 10.0, 2.0], + modes: Some([Variation::Rotation, Variation::Fixed, Variation::Pressure]), + }), + }, + Brush { + name: "Spray".into(), + kind: BrushKind::Scatter(Scatter { + art: brush_art_node(), + size: (50.0, 150.0), + spacing: (120.0, 200.0), + scatter: (-25.0, 25.0), + rotation: (-45.0, 30.0), + rotation_relative_to_path: true, + colorization: Colorization::Tints, + modes: Some([Variation::Random, Variation::Fixed, Variation::Pressure, Variation::Random]), + }), + }, + Brush { name: "Ribbon".into(), kind: art(Colorization::Tints) }, + Brush { name: "Plain ribbon".into(), kind: art(Colorization::None) }, + ] +} + +/// A path stroked red at `width` with the brush `brush`, on the first layer of `doc`. +fn brushed(doc: &mut Document, brush: &str, from: (f64, f64), to: (f64, f64), width: f64) { + let mut line = + Node::path(doc.alloc_id(), shapes::line(from.into(), to.into()), Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), width)); + line.appearance.stroke_mut().unwrap().brush = Some(brush.into()); + put(doc, line); +} + +/// [`brush_library`] and a path with the Calligraphic brush and one with the Art brush in Tints. +fn brushes_document() -> Document { + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + brushed(&mut doc, "Nib", (20.0, 50.0), (280.0, 60.0), 2.0); + brushed(&mut doc, "Ribbon", (20.0, 120.0), (280.0, 150.0), 1.5); + doc +} + +/// Brush art `n` as a tree to compare with [`same_tree`]: each object's kind, its anchors and handles, +/// the fills and strokes that paint (a stroke with its width), and its members in order. +fn art_tree(n: &Node) -> serde_json::Value { + let kind = match &n.kind { + NodeKind::Path { .. } => "path", + NodeKind::Compound { .. } => "compound", + NodeKind::Group { clip: false, .. } => "group", + _ => "other", + }; + let anchors: Vec<[f64; 6]> = match &n.kind { + NodeKind::Path { path, .. } => { + path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| [a.h_in.x, a.h_in.y, a.p.x, a.p.y, a.h_out.x, a.h_out.y])).collect() + } + _ => vec![], + }; + let paints: Vec = n + .appearance + .items + .iter() + .filter(|i| !i.paint().is_none()) + .map(|i| match i { + AppearanceItem::Fill(f) => serde_json::json!(["fill", f.paint]), + AppearanceItem::Stroke(s) => serde_json::json!(["stroke", s.paint, s.width]), + }) + .collect(); + let members: Vec = n.children().map(|c| c.iter().map(|m| art_tree(m)).collect()).unwrap_or_default(); + serde_json::json!({"kind": kind, "anchors": anchors, "paints": paints, "members": members}) +} + +/// Are trees `a` and `b` ([`art_tree`]) the same but for numbers within the written numbers' +/// precision (1/1000)? +fn same_tree(a: &serde_json::Value, b: &serde_json::Value) -> bool { + use serde_json::Value; + match (a, b) { + (Value::Number(x), Value::Number(y)) => (x.as_f64().unwrap() - y.as_f64().unwrap()).abs() <= 1e-3, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| same_tree(p, q)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same_tree(v, w))), + _ => a == b, + } +} + +/// The document's brushes come back in the Brushes panel's order with their settings and art: the +/// Calligraphic brush's channels, the Scatter brush's ranges, modes and Tints, the Art brushes' +/// colorizations. +#[test] +fn brushes_are_read_back_with_their_settings() { + use vectorcraft_brush::BrushKind; + let doc = brushes_document(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let text = program(&doc); + assert_eq!(text.matches("%AI8_BeginPluginObject").count(), 5, "the manager's order and four brushes"); + assert_eq!(text.matches("%AI8_BeginBrushPattern").count(), 3); + let back = vectorcraft_brush::library(&read_back(&doc)); + let lib = brush_library(); + assert_eq!(back.iter().map(|b| b.name.as_str()).collect::>(), ["Nib", "Spray", "Ribbon", "Plain ribbon"]); + assert_eq!(back[0], lib[0]); + let (BrushKind::Scatter(got), BrushKind::Scatter(want)) = (&back[1].kind, &lib[1].kind) else { panic!("scatter") }; + let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3; + assert!( + near(got.size, want.size) && near(got.spacing, want.spacing) && near(got.scatter, want.scatter) && near(got.rotation, want.rotation), + "{got:?}" + ); + assert_eq!((got.modes, got.colorization, got.rotation_relative_to_path), (want.modes, want.colorization, true)); + let (g, w) = (art_tree(&got.art), art_tree(&want.art)); + assert!(same_tree(&g, &w), "the art without its frame, with its paint and members in order: {g:#} for {w:#}"); + for (i, colorization) in [(2, vectorcraft_brush::Colorization::Tints), (3, vectorcraft_brush::Colorization::None)] { + let BrushKind::Art(a) = &back[i].kind else { panic!("art") }; + assert_eq!(a.colorization, colorization); + let (g, w) = (art_tree(&a.art), art_tree(&brush_art_node())); + assert!(same_tree(&g, &w), "the narrow block stays below the wide one, and no frame stays: {g:#} for {w:#}"); + let BrushKind::Art(want) = &lib[i].kind else { panic!("art") }; + assert_eq!(ArtBrushOptions::of(a), ArtBrushOptions::of(want)); + } +} + +/// An Art brush's options, without its art. +#[derive(Debug, PartialEq)] +struct ArtBrushOptions(serde_json::Value); + +impl ArtBrushOptions { + fn of(a: &vectorcraft_brush::ArtBrush) -> Self { + let mut v = serde_json::to_value(a).unwrap(); + v.as_object_mut().unwrap().remove("art"); + Self(v) + } +} + +/// A path with the Calligraphic brush, and one with an Art brush in Tints whose art bends along the +/// path, come back as those paths with the brush on their stroke at its weight, not as their drawn +/// look; a path with the Art brush that keeps its art's colors comes back as its drawn look. +#[test] +fn brushed_paths_are_read_back_brushed() { + let mut doc = brushes_document(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 2); + for (n, (brush, width)) in art.iter().zip([("Nib", 2.0), ("Ribbon", 1.5)]) { + assert!(matches!(n.kind, NodeKind::Path { .. }), "{brush}: the path, not its look: {n:#?}"); + let st = n.appearance.stroke().unwrap(); + assert_eq!((st.brush.as_deref(), st.width, st.paint.color()), (Some(brush), width, Some(Color::rgb(1.0, 0.0, 0.0)))); + assert!(n.appearance.fill().is_none_or(|f| f.paint.is_none())); + } + let ours = doc.layers[0].children().unwrap(); + assert_eq!(art[0].path_data(), ours[0].path_data()); + assert_eq!(art[1].path_data(), ours[1].path_data()); + brushed(&mut doc, "Plain ribbon", (20.0, 180.0), (280.0, 180.0), 1.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + assert!(matches!(read_back(&doc).layers[0].children().unwrap()[2].kind, NodeKind::Group { .. })); +} + +/// A compound path with the Calligraphic brush comes back as that compound path with the brush on its +/// stroke; a filled ellipse with it comes back with its fill and the brush. +#[test] +fn brushed_compound_and_filled_paths_are_read_back_brushed() { + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + let mut stroke = StrokeLayer::new(solid(Color::rgb(1.0, 0.0, 0.0)), 2.0); + stroke.brush = Some("Nib".into()); + let ring = ring(&mut doc, Appearance { items: vec![AppearanceItem::Stroke(stroke.clone())], effects: vec![], contents_index: None }); + put(&mut doc, ring); + let fill = vectorcraft_doc::FillLayer::new(solid(Color::rgb(0.2, 0.6, 0.3))); + let ap = Appearance { items: vec![AppearanceItem::Fill(fill), AppearanceItem::Stroke(stroke)], effects: vec![], contents_index: None }; + let ellipse = Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(20.0, 120.0, 140.0, 180.0)), ap); + put(&mut doc, ellipse); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STROKES || w == writer::BRUSHES), "{:?}", out.warnings); + let back = read_back(&doc); + let (art, ours) = (back.layers[0].children().unwrap(), doc.layers[0].children().unwrap()); + assert!(matches!(art[0].kind, NodeKind::Compound { .. }), "the compound path, not its look: {:#?}", art[0]); + assert!(same_tree(&art_tree(&art[0])["members"], &art_tree(&ours[0])["members"]), "{:#?}", art[0]); + assert!(matches!(art[1].kind, NodeKind::Path { .. }), "the ellipse, not its look: {:#?}", art[1]); + assert!(same_tree(&art_tree(&art[1])["anchors"], &art_tree(&ours[1])["anchors"]), "{:#?}", art[1]); + assert_eq!(art[1].appearance.fill().and_then(|f| f.paint.color()), Some(Color::rgb(0.2, 0.6, 0.3))); + for n in art { + let st = n.appearance.stroke().unwrap(); + assert_eq!((st.brush.as_deref(), st.width), (Some("Nib"), 2.0)); + } +} + +/// A path with an Art brush in Tints whose art doesn't bend along the path (its art has a painted +/// stroke) comes back as its drawn look, with the stroke warning; the brush itself comes back. +#[test] +fn an_art_brush_whose_art_doesnt_bend_keeps_its_paths_look() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Colorization}; + let mut art = brush_art_node(); + if let Some(c) = art.children_mut() { + for m in c.iter_mut() { + Arc::make_mut(m).appearance = Appearance::basic(black_fill(), black_fill(), 1.0).into(); + } + } + let brush = + Brush { name: "Stroked ribbon".into(), kind: BrushKind::Art(ArtBrush { art, colorization: Colorization::Tints, ..ArtBrush::default() }) }; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, std::slice::from_ref(&brush)); + brushed(&mut doc, "Stroked ribbon", (20.0, 120.0), (280.0, 150.0), 1.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = read_back(&doc); + assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Group { .. })); + let lib = vectorcraft_brush::library(&back); + let (BrushKind::Art(got), BrushKind::Art(want)) = (&lib[0].kind, &brush.kind) else { panic!("art") }; + assert!(same_tree(&art_tree(&got.art), &art_tree(&want.art)), "{:#?}", got.art); +} + +/// Settings the other tests don't use come back: a Calligraphic brush varying with tilt and bearing, +/// a Scatter brush keeping its art's colors whose rotation doesn't follow the path, and brushes whose +/// modes aren't given (the reader gives them, the same ones). +#[test] +fn more_brush_settings_are_read_back() { + use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; + let tilted = Calligraphic { + angle: -40.0, + roundness: 75.0, + size: 9.0, + variation: [10.0, 5.0, 3.0], + modes: Some([Variation::Tilt, Variation::Bearing, Variation::Random]), + }; + let scatter = Scatter { + art: brush_art_node(), + size: (80.0, 80.0), + spacing: (100.0, 100.0), + scatter: (0.0, 0.0), + rotation: (15.0, 15.0), + rotation_relative_to_path: false, + colorization: Colorization::None, + modes: Some([Variation::Tilt, Variation::Bearing, Variation::Fixed, Variation::Rotation]), + }; + let lib = vec![ + Brush { name: "Tilted".into(), kind: BrushKind::Calligraphic(tilted.clone()) }, + Brush { name: "Still".into(), kind: BrushKind::Scatter(scatter.clone()) }, + Brush { name: "Unset nib".into(), kind: BrushKind::Calligraphic(Calligraphic { modes: None, ..tilted }) }, + Brush { name: "Unset spray".into(), kind: BrushKind::Scatter(Scatter { modes: None, ..scatter }) }, + ]; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = vectorcraft_brush::library(&read_back(&doc)); + assert_eq!(back.len(), lib.len()); + let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3; + for (got, want) in back.iter().zip(&lib) { + assert_eq!(got.name, want.name); + match (&got.kind, &want.kind) { + (BrushKind::Calligraphic(g), BrushKind::Calligraphic(w)) => { + assert_eq!(g.modes(), w.modes(), "{}", want.name); + assert_eq!(Calligraphic { modes: None, ..g.clone() }, Calligraphic { modes: None, ..w.clone() }, "{}", want.name); + } + (BrushKind::Scatter(g), BrushKind::Scatter(w)) => { + assert_eq!(g.modes(), w.modes(), "{}", want.name); + assert!(near(g.size, w.size) && near(g.spacing, w.spacing) && near(g.scatter, w.scatter) && near(g.rotation, w.rotation), "{g:?}"); + assert_eq!((g.colorization, g.rotation_relative_to_path), (Colorization::None, false), "{}", want.name); + assert!(same_tree(&art_tree(&g.art), &art_tree(&w.art)), "{}: {:#?}", want.name, g.art); + } + _ => panic!("{}: {got:?}", want.name), + } + } +} + +/// Brush art without width or height, a straight stroked line, comes back as that line, without a +/// frame or a group around it: a Scatter brush's horizontal line and an Art brush's vertical one. +#[test] +fn brush_art_of_a_straight_line_comes_back_as_the_line() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Scatter}; + let line = |id: u64, to: (f64, f64)| { + Node::path(vectorcraft_doc::NodeId(id), shapes::line((0.0, 0.0).into(), to.into()), Appearance::basic(Paint::None, black_fill(), 2.0)) + }; + let lib = vec![ + Brush { name: "Dash".into(), kind: BrushKind::Scatter(Scatter { art: line(1, (40.0, 0.0)), ..Scatter::default() }) }, + Brush { name: "Post".into(), kind: BrushKind::Art(ArtBrush { art: line(2, (0.0, 40.0)), ..ArtBrush::default() }) }, + ]; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = vectorcraft_brush::library(&read_back(&doc)); + assert_eq!(back.len(), 2); + for (got, want) in back.iter().zip(&lib) { + let art = |b: &Brush| match &b.kind { + BrushKind::Scatter(s) => s.art.clone(), + BrushKind::Art(a) => a.art.clone(), + _ => panic!("{}", b.name), + }; + let (g, w) = (art_tree(&art(got)), art_tree(&art(want))); + assert!(same_tree(&g, &w), "{}: {g:#} for {w:#}", want.name); + } +} + +/// Brush art of a path with several subpaths is written as a compound path, which the reader reads as +/// a compound path of one path for each subpath, and is weighed as the reader weighs that against the +/// art it copies into one file's brushes: of two Art brushes whose art is a path of 30,000 squares, +/// the second is left out with a warning, so that what is written is what comes back. +#[test] +fn brush_art_of_a_path_with_subpaths_is_weighed_as_it_is_read() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let squares = |n: u32| { + let mut path = PathData::default(); + for i in 0..n { + let x = f64::from(i % 200) * 2.0; + let y = f64::from(i / 200) * 2.0; + path.subpaths.extend(shapes::rectangle(Rect::new(x, y, x + 1.0, y + 1.0)).subpaths); + } + Node::path(vectorcraft_doc::NodeId(1), path, Appearance::basic(black_fill(), Paint::None, 0.0)) + }; + let brush = |name: &str, n: u32| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art: squares(n), ..ArtBrush::default() }) }; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[brush("Two", 2)]); + let back = vectorcraft_brush::library(&read_back(&doc)); + let BrushKind::Art(a) = &back[0].kind else { panic!("art") }; + let tree = art_tree(&a.art); + assert_eq!(tree["kind"], "compound", "{tree:#}"); + assert_eq!(tree["members"].as_array().map(Vec::len), Some(2), "{tree:#}"); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[brush("Many 1", 30_000), brush("Many 2", 30_000)]); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let names: Vec = vectorcraft_brush::library(&read_back(&doc)).into_iter().map(|b| b.name).collect(); + assert_eq!(names, ["Many 1"]); + assert_eq!(program(&doc).matches("%AI8_BeginBrushPattern").count(), 1); +} + +/// Brush art that is a compound path is written as one path of all its members' subpaths, and is +/// weighed as the reader reads that back: a compound path of 24,000 members of two squares each is +/// read as 48,000 paths, within what the reader copies into one file's brushes, so the brush is +/// written without a warning; a compound path with one square comes back as one path. +#[test] +fn brush_art_of_a_compound_path_is_weighed_as_it_is_read() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let square = |i: u32| { + let x = f64::from(i % 400) * 2.0; + let y = f64::from(i / 400) * 2.0; + shapes::rectangle(Rect::new(x, y, x + 1.0, y + 1.0)).subpaths + }; + let compound = |members: u32, per: u32| { + let children = (0..members) + .map(|m| { + let path = PathData::new((0..per).flat_map(|k| square(m * per + k)).collect()); + Arc::new(Node::path(vectorcraft_doc::NodeId(2), path, Appearance::basic(black_fill(), Paint::None, 0.0))) + }) + .collect(); + let mut n = Node::path(vectorcraft_doc::NodeId(1), PathData::default(), Appearance::basic(black_fill(), Paint::None, 0.0)); + n.kind = NodeKind::Compound { children, rule: FillRule::NonZero }; + n + }; + let brush = |name: &str, art: Node| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art, ..ArtBrush::default() }) }; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[brush("One", compound(1, 1))]); + let back = vectorcraft_brush::library(&read_back(&doc)); + let BrushKind::Art(a) = &back[0].kind else { panic!("art") }; + assert_eq!(art_tree(&a.art)["kind"], "path", "{:#}", art_tree(&a.art)); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[brush("Many", compound(24_000, 2))]); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = vectorcraft_brush::library(&read_back(&doc)); + assert_eq!(back.len(), 1); + let BrushKind::Art(a) = &back[0].kind else { panic!("art") }; + assert_eq!(a.art.children().map(Vec::len), Some(48_000)); +} + +/// A path with a drop shadow and the Calligraphic brush keeps the brush and the effect, and its drawn +/// look doesn't warn about the brushed stroke; a group with a drop shadow whose member has the Scatter +/// brush still warns, as the member is written as its brush's art. +#[test] +fn a_brushed_path_with_effects_keeps_its_brush_without_the_stroke_warning() { + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + let mut line = Node::path( + doc.alloc_id(), + shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()), + Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 2.0), + ); + line.appearance.stroke_mut().unwrap().brush = Some("Nib".into()); + line.appearance.effects = vec![drop_shadow()]; + put(&mut doc, line); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(matches!(n.kind, NodeKind::Path { .. }), "the path, not its look: {n:#?}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Nib")); + assert_eq!(n.appearance.effects.len(), 1); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + let mut member = Node::path( + doc.alloc_id(), + shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()), + Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 2.0), + ); + member.appearance.stroke_mut().unwrap().brush = Some("Spray".into()); + let mut g = group(&mut doc, vec![member], false); + g.appearance.effects = vec![drop_shadow()]; + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); +} + +/// A brushed stroke that paints nothing isn't kept by an art style: a path with only such a stroke is +/// written without a style, and a document whose brushes are a new document's writes no brushes. +#[test] +fn a_brushed_stroke_that_paints_nothing_writes_no_brushes() { + let mut doc = Document::new(300.0, 200.0); + let mut line = + Node::path(doc.alloc_id(), shapes::line((20.0, 50.0).into(), (280.0, 60.0).into()), Appearance::basic(Paint::None, Paint::None, 1.0)); + line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + put(&mut doc, line); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let text = program(&doc); + assert!(!text.contains("%AI8_BeginPluginObject"), "no brushes"); + assert!(!text.contains("/KnownStyle"), "no art style"); +} + +/// The lines of `program` that hold brushes and what is drawn with them: the brush art, the plug-in +/// objects, the art styles and the layers. +fn brush_lines(program: &str) -> Vec<&str> { + let mut out = vec![]; + let mut inside = false; + for line in program.lines() { + if ["%AI8_BeginBrushPattern", "%AI8_BeginPluginObject", "%AI9_BeginArtStyles", "%%EndSetup"].iter().any(|m| line.starts_with(m)) { + inside = true; + } + if inside { + out.push(line); + } + if ["%AI8_EndBrushPattern", "%AI8_EndPluginObject", "%AI9_EndArtStyles"].iter().any(|m| line.starts_with(m)) { + inside = false; + } + } + out +} + +/// A document with brushes and brushed paths is written as the same bytes again, and what the reader +/// reads back is written with the same brushes, art styles and layers. +#[test] +fn brushes_write_the_same_bytes_again() { + let doc = brushes_document(); + let first = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); + let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap(); + let first = String::from_utf8(first.program).unwrap(); + let (again, first) = (brush_lines(&again), brush_lines(&first)); + let differ = again.iter().zip(&first).enumerate().find(|(_, (a, b))| a != b); + assert_eq!(differ, None, "the first line that differs"); + assert_eq!(again.len(), first.len()); +} + +/// Brushes the reader wouldn't read back as they are are left out with a warning, each on its own +/// beside a brush that comes back, and each changed from a brush that comes back by the one setting +/// that leaves it out: an Art brush whose art is a group with a fill of its own, a Scatter brush whose +/// art is a path without points, an Art brush whose art is a group without members, Pattern and Bristle +/// brushes, Art brushes flipped along or across, overlapping, at another width, with a width that +/// varies, in another direction or scale, Art and Scatter brushes in Tints and Shades or Hue Shift, a +/// brush whose name holds a slash and a second brush of a name. A brush left out doesn't take its +/// name from a later one. +#[test] +fn brushes_the_reader_cant_read_are_left_out() { + use vectorcraft_brush::{ArtBrush, ArtScale, Bristle, Brush, BrushKind, Colorization, Direction, PatternBrush, Variation}; + let lib = brush_library(); + let keep = lib[0].clone(); + let (BrushKind::Scatter(scatter), BrushKind::Art(art)) = (&lib[1].kind, &lib[2].kind) else { panic!("library") }; + let hue = Colorization::HueShift { key: Color::rgb(1.0, 0.0, 0.0) }; + let art_with = |f: &dyn Fn(&mut ArtBrush)| { + let mut a = art.clone(); + f(&mut a); + BrushKind::Art(a) + }; + let mut painted_group = brush_art_node(); + painted_group.appearance = Appearance::basic(black_fill(), Paint::None, 0.0).into(); + let empty_path = Node::path(vectorcraft_doc::NodeId(1), PathData::new(vec![]), Appearance::basic(black_fill(), Paint::None, 0.0)); + let cases: Vec<(&str, BrushKind)> = vec![ + ("painted group", art_with(&|a| a.art = painted_group.clone())), + ("empty path", BrushKind::Scatter(vectorcraft_brush::Scatter { art: empty_path.clone(), ..scatter.clone() })), + ("empty group", art_with(&|a| a.art = Node::group(vectorcraft_doc::NodeId(1), vec![]))), + ("pattern", BrushKind::Pattern(PatternBrush::default())), + ("bristle", BrushKind::Bristle(Bristle::default())), + ("flipped along", art_with(&|a| a.flip_along = true)), + ("flipped across", art_with(&|a| a.flip_across = true)), + ("overlap", art_with(&|a| a.overlap = true)), + ("width", art_with(&|a| a.width = 50.0)), + ("width varies", art_with(&|a| a.width_mode = Some(Variation::Pressure))), + ("direction", art_with(&|a| a.direction = Direction::RightToLeft)), + ("proportional", art_with(&|a| a.scale = ArtScale::Proportional)), + ("art tints and shades", art_with(&|a| a.colorization = Colorization::TintsAndShades)), + ("art hue shift", art_with(&|a| a.colorization = hue)), + ( + "scatter tints and shades", + BrushKind::Scatter(vectorcraft_brush::Scatter { colorization: Colorization::TintsAndShades, ..scatter.clone() }), + ), + ("scatter hue shift", BrushKind::Scatter(vectorcraft_brush::Scatter { colorization: hue, ..scatter.clone() })), + ]; + let left_out = |extra: Vec, why: &str| { + let mut doc = Document::new(300.0, 200.0); + let mut lib = vec![keep.clone()]; + lib.extend(extra); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{why}: {:?}", out.warnings); + assert_eq!(vectorcraft_brush::library(&read_back(&doc)), std::slice::from_ref(&keep), "{why}"); + }; + for (why, kind) in cases { + left_out(vec![Brush { name: "Other".into(), kind }], why); + } + left_out(vec![Brush { name: "A/B".into(), ..keep.clone() }], "slash"); + left_out(vec![Brush { kind: BrushKind::Bristle(Bristle::default()), ..keep.clone() }], "a second brush of the name"); + // A Pattern brush named "X" is left out, so a Calligraphic brush named "X" after it comes back. A + // path with "X" is drawn with the Pattern brush, so it comes back as its drawn look. + let mut doc = Document::new(300.0, 200.0); + let x = Brush { name: "X".into(), ..keep.clone() }; + vectorcraft_brush::store(&mut doc, &[Brush { name: "X".into(), kind: BrushKind::Pattern(PatternBrush::default()) }, x.clone()]); + brushed(&mut doc, "X", (20.0, 50.0), (280.0, 60.0), 2.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(vectorcraft_brush::library(&back), [x]); + let n = &back.layers[0].children().unwrap()[0]; + assert!(n.appearance.stroke().is_none_or(|s| s.brush.is_none()), "not with the other brush of the name: {n:#?}"); +} + +/// Brushes past the most the reader reads from one file are left out with a warning: of 4,097 +/// Calligraphic brushes, the first 4,096 come back. +#[test] +fn brushes_past_the_readers_count_are_left_out() { + let nib = brush_library().remove(0); + let lib: Vec = (0..4097).map(|i| vectorcraft_brush::Brush { name: format!("Nib {i}"), ..nib.clone() }).collect(); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = vectorcraft_brush::library(&read_back(&doc)); + assert_eq!(back.len(), 4096); + assert_eq!(back.last().map(|b| b.name.as_str()), Some("Nib 4095")); +} + +/// A brush whose art the editing data would change (a stroke inside the path, written as its +/// outline) is left out, also after an object on the layers was already written with that change. +#[test] +fn a_brush_whose_art_would_change_is_left_out() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let inside = |doc: &mut Document| { + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 8.0)), Appearance::basic(Paint::None, black_fill(), 2.0)); + n.appearance.stroke_mut().unwrap().align = vectorcraft_doc::StrokeAlign::Inside; + n + }; + let mut doc = Document::new(300.0, 200.0); + let on_layer = inside(&mut doc); + put(&mut doc, on_layer); + let keep = brush_library().remove(0); + let art = inside(&mut doc); + vectorcraft_brush::store( + &mut doc, + &[keep.clone(), Brush { name: "Outlined".into(), kind: BrushKind::Art(ArtBrush { art, ..ArtBrush::default() }) }], + ); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + assert_eq!(vectorcraft_brush::library(&read_back(&doc)), [keep]); +} + +/// Brushes past the art the reader copies into one file's brushes are left out with a warning, so +/// that what is written is what comes back: three Art brushes of 100,000 anchors each, of which the +/// reader keeps two. +#[test] +fn brushes_past_the_readers_art_limit_are_left_out() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let heavy = |id: u64| { + let points: Vec = + (0..100_000).map(|i| vectorcraft_geom::Point::new(f64::from(i) / 1000.0, if i % 2 == 0 { 0.0 } else { 4.0 })).collect(); + let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + Node::path(vectorcraft_doc::NodeId(id), path, Appearance::basic(black_fill(), Paint::None, 0.0)) + }; + let lib: Vec = + (1..=3).map(|i| Brush { name: format!("Heavy {i}"), kind: BrushKind::Art(ArtBrush { art: heavy(i), ..ArtBrush::default() }) }).collect(); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back: Vec = vectorcraft_brush::library(&read_back(&doc)).into_iter().map(|b| b.name).collect(); + assert_eq!(back, ["Heavy 1", "Heavy 2"]); + assert_eq!(program(&doc).matches("%AI8_BeginBrushPattern").count(), 2); + // The art of the brush left out isn't counted: there is room for it all with room for what is read. + let read = crate::import::objects_read(&out.program).unwrap(); + let room = export_within(&doc, &opts(), read).unwrap(); + assert!(!room.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{read}: {:?}", room.warnings); +} + +/// A brush whose art doesn't fit within the object limit is left out with the limit's warning, and +/// what its art took is given back: a later brush whose art fits comes back. +#[test] +fn a_brush_whose_art_passes_the_object_limit_leaves_room_for_the_next() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind}; + let squares = |n: u64| { + let block = |i: u64| { + let x = i as f64 * 10.0; + Arc::new(Node::path( + vectorcraft_doc::NodeId(i + 1), + shapes::rectangle(Rect::new(x, 0.0, x + 5.0, 5.0)), + Appearance::basic(black_fill(), Paint::None, 0.0), + )) + }; + Node::group(vectorcraft_doc::NodeId(100), (0..n).map(block).collect()) + }; + let art = |name: &str, n: u64| Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art: squares(n), ..ArtBrush::default() }) }; + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &[art("Big", 6), art("Small", 2)]); + let all = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", all.warnings); + let mut small = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut small, &[art("Small", 2)]); + let room = crate::import::objects_read(&export_ai_editing(&small, &opts()).unwrap().program).unwrap(); + let out = export_within(&doc, &opts(), room).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + assert!(crate::import::objects_read(&out.program).unwrap() <= room); + let mut data = ZSTD_MARKER.to_vec(); + data.extend(ruzstd::encoding::compress_to_vec(out.program.as_slice(), ruzstd::encoding::CompressionLevel::Fastest)); + let back = crate::ai_alone(&data).unwrap().0; + let names: Vec = vectorcraft_brush::library(&back).into_iter().map(|b| b.name).collect(); + assert_eq!(names, ["Small"]); +} + +/// A graphic style with a brushed stroke, which is left out, takes no room from the layers' objects: +/// a document with a new document's brushes and no brushed path writes no brush art, so with room for +/// what its layers write, every object comes back. +#[test] +fn a_graphic_style_with_a_brush_takes_no_room_from_the_layers() { + let mut doc = Document::new(300.0, 200.0); + for i in 0..3 { + let x = 20.0 + f64::from(i) * 40.0; + let square = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(x, 20.0, x + 30.0, 50.0)), Appearance::basic(black_fill(), Paint::None, 0.0)); + put(&mut doc, square); + } + let room = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + let mut stroke = StrokeLayer::new(solid(Color::rgb(1.0, 0.0, 0.0)), 1.0); + stroke.brush = Some("3 pt. Round".into()); + let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], effects: vec![], contents_index: None }; + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Brushed", ap)); + let out = export_within(&doc, &opts(), room).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{room}: {:?}", out.warnings); + let program = String::from_utf8(out.program).unwrap(); + assert!(!program.contains("%AI8_BeginBrushPattern"), "no brush art"); +} + +/// A graphic style whose brushed stroke paints nothing comes back in the Graphic Styles panel. +#[test] +fn a_graphic_style_with_an_unpainted_brushed_stroke_comes_back() { + let mut doc = Document::new(300.0, 200.0); + let mut ap = Appearance::basic(black_fill(), Paint::None, 1.0); + ap.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Unpainted brush", ap)); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + assert!(read_back(&doc).graphic_style("Unpainted brush").is_some()); +} + +/// A brush whose art doesn't fit within the object limit after a path was written with it on its +/// stroke is left out with both warnings, and the path comes back as its drawn look. +#[test] +fn a_brushed_path_whose_brush_art_doesnt_fit_comes_back_as_its_look() { + let ribbon = brush_library().remove(2); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, std::slice::from_ref(&ribbon)); + brushed(&mut doc, "Ribbon", (20.0, 120.0), (280.0, 150.0), 1.5); + let all = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT || w == writer::BRUSHES), "{:?}", all.warnings); + let room = crate::import::objects_read(&all.program).unwrap() - 1; + let out = export_within(&doc, &opts(), room).unwrap(); + for w in [writer::OBJECT_LIMIT, writer::BRUSHES] { + assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings); + } + assert!(crate::import::objects_read(&out.program).unwrap() <= room); + let mut data = ZSTD_MARKER.to_vec(); + data.extend(ruzstd::encoding::compress_to_vec(out.program.as_slice(), ruzstd::encoding::CompressionLevel::Fastest)); + let back = crate::ai_alone(&data).unwrap().0; + assert!(vectorcraft_brush::library(&back).is_empty()); + let n = &back.layers[0].children().unwrap()[0]; + assert!(matches!(n.kind, NodeKind::Group { .. }), "the path's drawn look: {n:#?}"); +} + +/// An empty Brushes panel comes back empty, and a document with a new document's brushes and no +/// brushed path writes no brushes (the reader gives such a file a new document's). +#[test] +fn an_empty_brushes_panel_comes_back_empty() { + let mut doc = Document::new(300.0, 200.0); + assert!(!program(&doc).contains("%AI8_BeginPluginObject")); + assert_eq!(vectorcraft_brush::library(&read_back(&doc)), vectorcraft_brush::library(&doc)); + vectorcraft_brush::store(&mut doc, &[]); + assert!(vectorcraft_brush::library(&read_back(&doc)).is_empty()); +} + +/// A path with one of a new document's brushes writes the brushes the reader reads, and keeps its +/// brush. The new document's Art brush with other options, its Pattern brushes and its Bristle +/// brushes are left out with a warning, so the file opens without them. +#[test] +fn a_new_documents_brush_on_a_path_writes_the_brushes() { + let mut doc = Document::new(300.0, 200.0); + brushed(&mut doc, "3 pt. Round", (20.0, 50.0), (280.0, 60.0), 1.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("3 pt. Round")); + let left_out = ["Arrow", "Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"]; + let want: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).filter(|n| !left_out.contains(n)).collect(); + let lib = vectorcraft_brush::library(&back); + assert_eq!(lib.iter().map(|b| b.name.as_str()).collect::>(), want); + assert_eq!(vectorcraft_brush::defaults().len(), want.len() + left_out.len()); +} + +/// A path with one of a new document's brushes that doesn't fit within the object limit is left +/// out, and no brushes are written: the file opens with a new document's brushes, all of them. +#[test] +fn a_new_documents_brush_on_a_path_past_the_object_limit_writes_no_brushes() { + let empty = Document::new(300.0, 200.0); + let room = crate::import::objects_read(&export_ai_editing(&empty, &opts()).unwrap().program).unwrap(); + let mut doc = Document::new(300.0, 200.0); + brushed(&mut doc, "3 pt. Round", (20.0, 50.0), (280.0, 60.0), 1.0); + let out = export_within(&doc, &opts(), room).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + assert!(!String::from_utf8(out.program).unwrap().contains("%AI8_BeginPluginObject")); +} + +/// On a large canvas the brushes are left out with a warning, and brushed paths come back as their +/// drawn look. +#[test] +fn a_large_canvas_leaves_the_brushes_out() { + let mut doc = Document::new(20000.0, 100.0); + vectorcraft_brush::store(&mut doc, &brush_library()); + brushed(&mut doc, "Nib", (20.0, 50.0), (280.0, 60.0), 2.0); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + assert!(!program(&doc).contains("%AI8_BeginPluginObject")); + assert!(matches!(read_back(&doc).layers[0].children().unwrap()[0].kind, NodeKind::Group { .. })); + // An empty panel is kept, without a warning. + let mut doc = Document::new(20000.0, 100.0); + vectorcraft_brush::store(&mut doc, &[]); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + assert!(vectorcraft_brush::library(&read_back(&doc)).is_empty()); +} + // ---------- variables ---------- /// A 200 × 100 document with variables: a text variable `name` bound to point type, a visibility diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index dea4ad0c24..970e945e51 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -15,6 +15,7 @@ use vectorcraft_pathops::BoolOp; use crate::ai_ops::{self, unit}; use crate::ps; +mod brushes; mod images; mod paints; mod styles; @@ -22,6 +23,8 @@ mod symbols; mod texts; mod variables; +#[cfg(test)] +pub(super) use brushes::BRUSHES; pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects}; #[cfg(test)] pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, same_appearance}; @@ -51,7 +54,7 @@ pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths tha pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps"; pub(super) const LIVE: &str = "blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; -const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; +pub(super) const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; pub(super) const ITEM_TRANSPARENCY: &str = "the opacity and blending modes of single fills and strokes are left out of the editing data for other apps"; pub(super) const LAYER_MASKS: &str = "opacity masks on layers are left out of the editing data for other apps"; @@ -255,6 +258,10 @@ pub(super) struct Writer<'a> { /// An object's drawn look is being written: no object in it is written with a style, and its /// effects are the style's. in_look: bool, + /// The brushes written ([`brushes`]). + brush_set: brushes::Brushes, + /// Brushes may be written; false on a large canvas, where they are left out. + pub(super) brushes_live: bool, } impl<'a> Writer<'a> { @@ -310,6 +317,8 @@ impl<'a> Writer<'a> { styled: HashMap::new(), styles: styles::Styles::default(), in_look: false, + brush_set: brushes::Brushes::default(), + brushes_live: true, objects: 0, left_out: 0, definition_objects: 0, @@ -1028,7 +1037,12 @@ impl<'a> Writer<'a> { AppearanceItem::Fill(f) => self.fill_object(path, rule, f, xy), AppearanceItem::Stroke(s) if self.direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy), AppearanceItem::Stroke(s) => { - self.warn(STROKES); + // In a drawn look a brushed stroke is kept by the object's art style, which is written + // only when the reader reads the brush back from it; a styled group's members are + // written again after the look, and warn there. + if !(self.in_look && s.brush.is_some()) { + self.warn(STROKES); + } self.wrote_fixed = true; if self.brush_art(path, s, xy) { return; diff --git a/crates/eps/src/ai_editing/writer/brushes.rs b/crates/eps/src/ai_editing/writer/brushes.rs new file mode 100644 index 0000000000..2c281e702d --- /dev/null +++ b/crates/eps/src/ai_editing/writer/brushes.rs @@ -0,0 +1,558 @@ +//! Brushes: the document's brushes as the `.ai` reader reads them ([`crate::import`]'s `.ai` reader, +//! `brush.rs`), and the brush filter of a brushed stroke in an art style (`style.rs`). +//! +//! Each brush is a plug-in object (`%AI8_BeginPluginObject`, the plug-in's name, the brush's name, +//! its parameters as strings that `-` continues and `.` ends, `%AI8_EndPluginObject`), after the +//! brush manager's order of them all, in a non-printing block after the symbols. The art of a +//! Scatter or Art brush is a `%AI8_BeginBrushPattern` section (its name; a group of the art's frame, +//! an unpainted rectangle at the art's geometric bounds, and the art, or the art alone when it has no +//! width or height; its end) in a non-printing block before the symbols. Each brush is checked with +//! the reader's own reading ([`crate::import::read_brush_object`]) before it is written: one the +//! reader would read as another brush is left out. + +use std::collections::HashSet; +use std::fmt::Write; +use std::sync::Arc; + +use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Calligraphic, Colorization, Direction, Scatter, Variation}; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{Affine, PathData, SubPath}; + +use super::{Writer, Xy}; +use crate::ai_ops; +use crate::ps; + +pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't stretch their art along the path from left to right at 100% unflipped without overlap, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps"; + +/// Most brushes written (the reader's limit). +const MAX_BRUSHES: usize = 4096; + +/// Most bytes of a plug-in object's parameters in one of its strings. +const CHUNK: usize = 60; + +/// A brush written as a plug-in object. +pub(super) struct BrushOut { + pub brush: Brush, + pub plugin: &'static str, + pub params: String, + /// The art its parameters name, as the reader reads it. + pub art: Option, + /// Its art section to write ([`Writer::brush_blocks`] writes it). + section: Option, + /// A path whose art style names it at its own settings is read with the brush on its stroke. + pub live: bool, +} + +/// A brush's art section to write: its name, the art and the space it is written in +/// ([`Writer::brush_art_section`]). +type SectionArt = (String, Node, Affine); + +/// The brushes written and their art. +#[derive(Default)] +pub(super) struct Brushes { + /// The document's brushes have been worked out. + done: bool, + pub written: Vec, + /// The art sections named so far. + sections: usize, + /// A brush of the document was left out. + left_out: bool, + /// How many objects were written with a brush on their stroke. + pub live_written: usize, + /// The brushes written as the reader reads them, for checking art styles; made once, when + /// the first style is checked ([`Writer::brushed_style_reads`]). + read: Option, +} + +/// A number with at most six decimals, as brush parameters hold factors. +fn num6(s: &mut String, v: f64) { + let v = if v.is_finite() { (v * 1e6).round() / 1e6 } else { 0.0 }; + if v == v.trunc() && v.abs() < 1e15 { + let _ = write!(s, "{}", v as i64); + return; + } + let start = s.len(); + let _ = write!(s, "{v:.6}"); + let keep = s[start..].trim_end_matches('0').trim_end_matches('.').len(); + s.truncate(start + keep); +} + +/// `vs` as [`num6`] numbers separated by spaces. +fn nums6(vs: &[f64]) -> String { + let mut s = String::new(); + for (i, v) in vs.iter().enumerate() { + if i > 0 { + s.push(' '); + } + num6(&mut s, *v); + } + s +} + +/// The code a brush's parameters give how a value varies, in the order of the Brush Options menu. +fn code(v: Variation) -> f64 { + match v { + Variation::Fixed => 0.0, + Variation::Random => 1.0, + Variation::Pressure => 2.0, + Variation::Tilt => 4.0, + Variation::Bearing => 5.0, + Variation::Rotation => 6.0, + } +} + +/// The colorization code of Scatter and Art brushes' parameters: `None` for one the reader doesn't read. +fn colorization_code(c: Colorization) -> Option { + match c { + Colorization::None => Some(0.0), + Colorization::Tints => Some(1.0), + Colorization::TintsAndShades | Colorization::HueShift { .. } => None, + } +} + +/// A Calligraphic brush's parameters: 1, the diameter, roundness and angle as (low, high) pairs, +/// the codes of how the diameter, roundness and angle vary, and 0. +fn calligraphic_params(c: &Calligraphic) -> String { + let [angle, roundness, size] = [c.angle, c.roundness, c.size]; + let [av, rv, dv] = c.variation; + let [am, rm, dm] = c.modes().map(code); + nums6(&[1.0, size - dv, size + dv, roundness - rv, roundness + rv, angle - av, angle + av, dm, rm, am, 0.0]) +} + +/// A Scatter brush's parameters, its art the section `art`: `None` for a colorization the reader +/// doesn't read. +fn scatter_params(s: &Scatter, art: &str) -> Option { + let f = |v: f64| v / 100.0; + let m = s.modes().map(code); + let numbers = [ + colorization_code(s.colorization)?, + f(s.size.0), + f(s.size.1), + m[0], + f(s.spacing.0), + f(s.spacing.1), + m[1], + f(s.scatter.0), + f(s.scatter.1), + m[2], + s.rotation.0, + s.rotation.1, + m[3], + f64::from(u8::from(s.rotation_relative_to_path)), + 0.0, + ]; + Some(format!("2 /{art}/ 5 0 0 0 / {}", nums6(&numbers))) +} + +/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read +/// (it reads an Art brush that stretches its art along the path from left to right at 100%, +/// unflipped, without overlap, in Tints or keeping its colors). +fn art_params(a: &ArtBrush, art: &str) -> Option { + let plain = a.direction == Direction::LeftToRight + && matches!(a.scale, ArtScale::Stretch) + && a.width == 100.0 + && a.width_mode.is_none() + && !a.flip_along + && !a.flip_across + && !a.overlap; + if !plain { + return None; + } + let c = colorization_code(a.colorization)?; + let numbers = [1.0, c, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]; + Some(format!("2 / {art}/ / / / / 0 0/ 5 0 0 0 / {}", nums6(&numbers))) +} + +/// Is `art` written as the reader reads it back, within a brush: paths and compound paths with +/// points and groups with members (not clipping, without paint of their own), each opaque in the +/// normal blending mode, without effects or opacity masks, with at most one fill and one plain +/// stroke (no brush, width profile, arrowheads, alignment off the path or dashes fitted to corners) +/// over it, each in a color or none? A path without points isn't written ([`Writer::plain_object`]). +fn plain_art(art: &Node) -> bool { + let mut plain = true; + art.walk(&mut |n| { + let kind = match &n.kind { + NodeKind::Path { path, guide: false, clipping: false, .. } => path.subpaths.iter().any(|sp| !sp.anchors.is_empty()), + NodeKind::Compound { .. } => super::compound_path(n).subpaths.iter().any(|sp| !sp.anchors.is_empty()), + NodeKind::Group { children, clip: false } => !children.is_empty(), + _ => false, + }; + let items = &n.appearance.items; + let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count(); + let strokes = items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count(); + let order = !matches!(items.as_slice(), [AppearanceItem::Stroke(_), AppearanceItem::Fill(_)]); + let quiet = items.iter().all(|i| { + i.effects().is_empty() + && i.opacity() >= 1.0 + && i.blend() == BlendMode::Normal + && matches!(i.paint(), Paint::Solid { .. } | Paint::None) + && !matches!(i, AppearanceItem::Stroke(s) if !vectorcraft_effects::stroke::is_plain(s)) + }); + // A group's own fills and strokes aren't written. + let group_paint = matches!(n.kind, NodeKind::Group { .. }) && items.iter().any(|i| !i.paint().is_none()); + plain &= kind + && !group_paint + && fills <= 1 + && strokes <= 1 + && order + && quiet + && n.appearance.effects.is_empty() + && n.extras().mask.is_none() + && n.opacity >= 1.0 + && n.blend == BlendMode::Normal; + }); + plain +} + +/// `art` (checked by [`plain_art`]) with its structure as the reader reads it back. A path, or a +/// compound path with the subpaths of all its members as one path ([`super::compound_path`]), is +/// written as one path when it has one subpath with points and as a compound path when it has more +/// ([`Writer::plain_object`]); the reader reads that compound path as one path for each subpath, +/// each with the object's paint. +fn as_read(art: &mut Node) { + let (subpaths, rule) = match &art.kind { + NodeKind::Path { path, rule, .. } => (path.subpaths.clone(), *rule), + NodeKind::Compound { rule, .. } => (super::compound_path(art).subpaths, *rule), + _ => { + if let Some(children) = art.children_mut() { + for c in children.iter_mut() { + as_read(Arc::make_mut(c)); + } + } + return; + } + }; + let mut subpaths: Vec = subpaths.into_iter().filter(|sp| !sp.anchors.is_empty()).collect(); + let path = |sp: SubPath| { + let mut p = Node::path(art.id, PathData::single(sp), Appearance::default()); + p.appearance = art.appearance.clone(); + p + }; + match subpaths.len() { + 0 => {} + 1 => { + let Some(sp) = subpaths.pop() else { return }; + art.kind = path(sp).kind; + if let NodeKind::Path { rule: r, .. } = &mut art.kind { + *r = rule; + } + } + _ => { + let children = subpaths.into_iter().map(|sp| Arc::new(path(sp))).collect(); + art.kind = NodeKind::Compound { children, rule }; + } + } +} + +/// Do brushes `a` and `b` have the same settings, within the written numbers' precision, whatever +/// their art and however their modes are given? +fn same_settings(a: &Brush, b: &Brush) -> bool { + let norm = |b: &Brush| -> serde_json::Value { + let mut b = b.clone(); + let none = || Node::group(NodeId(0), vec![]); + match &mut b.kind { + BrushKind::Calligraphic(c) => c.modes = Some(c.modes()), + BrushKind::Scatter(s) => { + s.modes = Some(s.modes()); + s.art = none(); + } + BrushKind::Art(a) => a.art = none(), + BrushKind::Pattern(_) | BrushKind::Bristle(_) => {} + } + serde_json::to_value(&b).unwrap_or(serde_json::Value::Null) + }; + super::styles::near(&norm(a), &norm(b)) +} + +/// A plug-in object: its plug-in's name, its name, and `params` in strings of at most [`CHUNK`] bytes. +fn plugin_object(plugin: &str, name: &str, params: &str) -> String { + let mut s = format!("%AI8_BeginPluginObject\n{}\n{}\n", ps::bytes(plugin.as_bytes()), ps::bytes(name.as_bytes())); + let chunks: Vec<&[u8]> = params.as_bytes().chunks(CHUNK).collect(); + if chunks.is_empty() { + s.push_str("() .\n"); + } + for (i, c) in chunks.iter().enumerate() { + let _ = writeln!(s, "{} {}", ps::bytes(c), if i + 1 == chunks.len() { "." } else { "-" }); + } + s.push_str("%AI8_EndPluginObject\n"); + s +} + +impl Writer<'_> { + /// Work out, once, which of the document's brushes are written: each one the reader reads back + /// with its settings. Their art is written with the brushes ([`Self::brush_blocks`]). + pub(super) fn brush_set(&mut self) { + if self.brush_set.done { + return; + } + self.brush_set.done = true; + let doc = self.doc; + let lib = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)).clone(); + if !self.brushes_live { + self.brush_set.left_out = !lib.is_empty(); + return; + } + // A name is taken by a brush written, as the reader takes it by a brush it keeps. A brush after + // another of its name isn't the one a path of that name is drawn with, so no path is written + // with it on its stroke. + let mut names: HashSet = HashSet::new(); + let mut seen: HashSet = HashSet::new(); + for b in lib { + let fits = !b.name.contains('/') && !names.contains(&b.name) && self.brush_set.written.len() < MAX_BRUSHES; + let first = seen.insert(b.name.clone()); + match fits.then(|| self.brush_out(&b)).flatten() { + Some(mut out) => { + names.insert(b.name.clone()); + out.live &= first; + self.brush_set.written.push(out); + } + None => self.brush_set.left_out = true, + } + } + // Read together, as one file's: the reader copies only so much art into one file's brushes. + let objects: Vec<(&str, &str, &str, Option<&Node>)> = + self.brush_set.written.iter().map(|b| (b.plugin, b.brush.name.as_str(), b.params.as_str(), b.art.as_ref())).collect(); + let kept = crate::import::brushes_kept(&objects); + if kept.iter().any(|k| !k) { + self.brush_set.left_out = true; + let mut keep = kept.into_iter(); + self.brush_set.written.retain(|_| keep.next().unwrap_or(false)); + } + } + + /// Brush `b` as it is written, with its art section's name, art and placement + /// ([`Self::brush_art_section`] writes it): `None` when the reader wouldn't read it back. + fn brush_out(&mut self, b: &Brush) -> Option { + let plugin = crate::import::brush_plugin_name(&b.kind); + let section = format!("Brush Art {}", self.brush_set.sections + 1); + // The art as the reader reads it: a section is read with y down, and an Art brush's art is + // turned over after that (its section's y up is the right of the path). + let (params, art) = match &b.kind { + BrushKind::Calligraphic(c) => (calligraphic_params(c), None), + BrushKind::Scatter(s) => (scatter_params(s, §ion)?, Some((s.art.clone(), Affine::FLIP_Y))), + BrushKind::Art(a) => (art_params(a, §ion)?, Some((a.art.clone(), Affine::IDENTITY))), + BrushKind::Pattern(_) | BrushKind::Bristle(_) => return None, + }; + let read_art = match &art { + Some((a, to_section)) => { + if !plain_art(a) { + return None; + } + let mut read = a.clone(); + if *to_section == Affine::IDENTITY { + read.transform(Affine::FLIP_Y, false); + } + as_read(&mut read); + Some(read) + } + None => None, + }; + let read = crate::import::read_brush_object(plugin, &b.name, ¶ms, read_art.as_ref())?; + if !same_settings(&read.brush, b) { + return None; + } + if art.is_some() { + self.brush_set.sections += 1; + } + let section = art.map(|(a, to_section)| (section, a, to_section)); + Some(BrushOut { brush: b.clone(), plugin, params, art: read_art, section, live: read.live }) + } + + /// A brush's art section `name`: `art` written with `to_section` taking the document's space to + /// the section's (y up), from the state a definition starts in. Its objects are counted as the + /// reader counts them ([`Self::take_objects`]: the group, the frame and the art). `None` when + /// writing it warns: what it took is given back, and when it didn't fit within the object limit, + /// the limit's warning is kept. + fn brush_art_section(&mut self, name: &str, art: &Node, to_section: Affine) -> Option { + let before = self.charges(); + // The art's own warnings, apart from those the document already has (a warning is kept once). + let warnings = std::mem::take(&mut self.warnings); + let saved = ( + std::mem::take(&mut self.body), + (self.to_art, self.grad_space, self.canvas), + self.state.take(), + self.gs.replace(Xy::OPAQUE), + self.clip.take(), + (self.block_start, self.block_end), + // The art is the brush's own: not on `%_` lines, in a drawn look or in a symbol that + // first uses the brush. + (std::mem::take(&mut self.hidden), std::mem::take(&mut self.in_look), std::mem::take(&mut self.in_symbol)), + ); + (self.to_art, self.grad_space, self.canvas, self.block_end) = (to_section, to_section, to_section, usize::MAX); + self.state(false, false); + // The art in a group after its frame, an unpainted rectangle around it. Art without width or + // height (a straight line) is written alone: the reader keeps a frame of no area as art. + let frame = art + .geometric_bounds() + .filter(|b| b.width() > 0.0 && b.height() > 0.0) + .and_then(|b| vectorcraft_geom::shapes::rectangle(b).subpaths.first().cloned()); + match frame { + Some(frame) if self.take_objects(2) => { + self.line("u\n0 XR"); + ai_ops::subpath(&mut self.body, to_section, &frame); + self.line("n"); + self.nest += 1; + self.node(art); + self.nest -= 1; + self.line("U"); + } + Some(_) => {} + None => { + self.nest += 1; + self.node(art); + self.nest -= 1; + } + } + let body; + ( + body, + (self.to_art, self.grad_space, self.canvas), + self.state, + self.gs, + self.clip, + (self.block_start, self.block_end), + (self.hidden, self.in_look, self.in_symbol), + ) = saved; + let text = std::mem::replace(&mut self.body, body); + if !std::mem::replace(&mut self.warnings, warnings).is_empty() { + self.give_back(before, &text); + if self.left_out_since(before) { + self.warn(super::OBJECT_LIMIT); + } + return None; + } + self.keep_since(before); + Some(format!("%AI8_BeginBrushPattern\n{}\n{text}%AI8_EndBrushPattern\n", ps::bytes(name.as_bytes()))) + } + + /// The written brush named `name` that a path can be written with live. + pub(super) fn live_brush(&self, name: &str) -> Option<&BrushOut> { + self.brush_set.written.iter().find(|b| b.live && b.brush.name == name) + } + + /// The basic filter of stroke `st` with the brush it names: the brush's plug-in as the filter, + /// its settings for the object, the stroke's style and the brush by name. `None` when the brush + /// isn't written live. + pub(super) fn brush_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> { + let name = st.brush.as_deref()?; + self.brush_set(); + let (plugin, kind) = self.live_brush(name).map(|b| (b.plugin, b.brush.kind.clone()))?; + if !matches!(st.paint, Paint::Solid { .. }) || st.overprint { + return None; + } + let ops = self.color_ops(&st.paint, true); + let mut d = String::new(); + let title = match &kind { + BrushKind::Calligraphic(c) => { + let modes = c.modes().map(code); + for (key, value, variation, mode) in [ + ("Diameter", c.size, c.variation[2], modes[2]), + ("Roundness", c.roundness, c.variation[1], modes[1]), + ("Angle", c.angle, c.variation[0], modes[0]), + ] { + let _ = writeln!(d, "{} /Real ({key}) ,", ps::num(value)); + let _ = writeln!(d, "{} /Real ({key}Variation) ,", ps::num(variation)); + let _ = writeln!(d, "{} /Int ({key}DependsOn) ,", mode as i64); + } + d.push_str("0 /Int (AngleRelativeTo) ,\n"); + "(Calligraphic Brush) /Title ,\n" + } + BrushKind::Art(_) => { + d.push_str("15 /Int (Adobe Effect Expand Before Version) ,\n"); // brand-ok: the entry's name in the files + for (key, value, kind) in [ + ("StartSegmentLength", "0", "Real"), + ("EndSegmentLength", "0", "Real"), + ("Counterclockwise", "0", "Int"), + ("MinScaling", "1", "Real"), + ("MaxScaling", "1", "Real"), + ("Colorization", "1", "Int"), + ("Spacing", "0", "Real"), + ("Adjustment", "1", "Int"), + ("Proportional", "0", "Bool"), + ("Segmented", "0", "Bool"), + ("UpsideDown", "0", "Int"), + ("FoldAdjustment", "1", "Bool"), + ("ScaleYDependsOn", "0", "Int"), + ("Scaling", "1", "Real"), + ] { + let _ = writeln!(d, "{value} /{kind} ({key}) ,"); + } + "(Art Brush) /Title ,\n" + } + _ => return None, + }; + super::styles::basic(s, plugin, true, visible); + s.push_str(title); + s.push_str("2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n"); + s.push_str(&d); + s.push_str("/StrokeStyle : 0 R\n"); + s.push_str(&ops); + s.push_str("0 1 0 0 0 Xy\n"); + ai_ops::line_style(s, st); + s.push_str("0 XR\n1 0 Xd\n/Def ; \n (StrokeStyle) ,\n"); + let quoted = ps::bytes(name.as_bytes()); + let _ = + write!(s, "/GObjRef : {quoted} {} /PluginObject ; (Brush) ,\n{quoted} /UnicodeString (DisplayString) ,\n", ps::bytes(plugin.as_bytes())); + s.push_str(super::styles::END_BASIC); + Some(()) + } + + /// Does the reader read the brushed strokes of style text `text` with their brushes on them? + pub(super) fn brushed_style_reads(&mut self, text: &str) -> bool { + self.brush_set(); + let section = super::styles::known_style("x", text); + let written = &self.brush_set.written; + let read = self.brush_set.read.get_or_insert_with(|| { + let brushes: Vec<(&str, &str, &str, Option<&Node>)> = + written.iter().map(|b| (b.plugin, b.brush.name.as_str(), b.params.as_str(), b.art.as_ref())).collect(); + crate::import::BrushesRead::new(&brushes) + }); + read.style_reads(section.as_bytes(), "x") + } + + /// The brushes' art in a non-printing block, then the brush manager's order and the brushes in + /// two more, when the document has brushes of its own or an object is written with a brush on + /// its stroke (a file without brushes opens with a new document's). Warns when a brush is left + /// out. + pub(in crate::ai_editing) fn brush_blocks(&mut self) -> (String, String) { + let stored = self.doc.unknown.contains_key(vectorcraft_brush::DOC_KEY); + if !stored && self.brush_set.live_written == 0 { + return (String::new(), String::new()); + } + self.brush_set(); + // The art is written now that the file holds it, after the layers' objects. A brush whose art + // doesn't fit is left out; a path written with it on its stroke opens as its drawn look. + let mut sections = String::new(); + for mut b in std::mem::take(&mut self.brush_set.written) { + if let Some((name, art, to_section)) = b.section.take() { + let Some(text) = self.brush_art_section(&name, &art, to_section) else { + self.brush_set.left_out = true; + continue; + }; + sections.push_str(&text); + } + self.brush_set.written.push(b); + } + if self.brush_set.left_out { + self.warn(BRUSHES); + } + // On a large canvas an empty panel is still written: the manager alone, with no art to scale. + let empty = self.brushes.as_ref().is_some_and(|l| l.is_empty()); + if !self.brushes_live && !(stored && empty) { + return (String::new(), String::new()); + } + let art = if sections.is_empty() { String::new() } else { format!("%AI5_Begin_NonPrinting\nNp\n{sections}%AI5_End_NonPrinting--\n") }; + let manager = crate::import::BRUSH_MANAGER; + let order: String = self.brush_set.written.iter().map(|b| format!(" {}/ {}/", b.plugin, b.brush.name)).collect(); + let mut objects = format!("%AI5_Begin_NonPrinting\nNp\n{}%AI5_End_NonPrinting--\n", plugin_object(manager, manager, &order)); + if !self.brush_set.written.is_empty() { + objects.push_str("%AI5_Begin_NonPrinting\nNp\n"); + for b in &self.brush_set.written { + objects.push_str(&plugin_object(b.plugin, &b.brush.name, &b.params)); + } + objects.push_str("%AI5_End_NonPrinting--\n"); + } + (art, objects) + } +} diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index c28c538bcf..29eb2bf910 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -40,8 +40,9 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { } /// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]), -/// by id: paths and compound paths with points and groups, with live effects or linked to a graphic -/// style, without Opacity & Mask Define Knockout Shape (the reader takes an object's transparency +/// by id: paths and compound paths with points and groups, with live effects, linked to a graphic +/// style or (paths and compound paths) with a brush on a stroke, without Opacity & Mask Define +/// Knockout Shape (the reader takes an object's transparency /// from its look, which doesn't carry it). A group's members are paths, compound paths or such /// groups, none with effects or anything baking changes. pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap> { @@ -76,7 +77,8 @@ fn bakes_members(n: &Node) -> bool { /// Whether `n` may be written with an art style ([`styled_objects`]). fn candidate(n: &Node) -> bool { - let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some(); + let brushed = brushed(n) && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some() || brushed; if !styled || n.knockout_shape { return false; } @@ -88,6 +90,12 @@ fn candidate(n: &Node) -> bool { } } +/// Does `n` have a stroke with a brush that paints, one that its art style holds ([`Writer::style_text`] +/// leaves out what paints nothing)? +fn brushed(n: &Node) -> bool { + n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && !s.paint.is_none())) +} + /// Does `path` write anything ([`Writer::plain_object`] skips subpaths without points)? fn has_points(path: &vectorcraft_geom::PathData) -> bool { path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) @@ -144,7 +152,7 @@ impl Styles { } /// One style as the reader's section holds it: `/KnownStyle :`, `(name) /Name ,`, its text. -fn known_style(name: &str, text: &str) -> String { +pub(super) fn known_style(name: &str, text: &str) -> String { format!("/KnownStyle :\n{} /Name ,\n{text}", ps::bytes(name.as_bytes())) } @@ -172,7 +180,7 @@ const NO_LINE: &str = "0 J 0 j 1 w 10 M []0 d\n"; /// A basic filter's start: its name and kind numbers (`1 0` for an effect, `0 0` for the /// style's own filters), `/Visible` and the dictionary's start. -fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { +pub(super) fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { let _ = write!(s, "/BasicFilter :\n{} {} 0 /Filter ,\n{} /Visible ,\n", ps::bytes(name.as_bytes()), u8::from(effect), u8::from(visible)); } @@ -180,7 +188,7 @@ fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { const CONDUIT: &str = "/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n"; /// A basic filter's end. -const END_BASIC: &str = "; /Dict ;\n /Part ,\n"; +pub(super) const END_BASIC: &str = "; /Dict ;\n /Part ,\n"; /// The blend filter of transparency `xy`. fn blend_filter(s: &mut String, xy: Xy) { @@ -228,7 +236,7 @@ fn flag(p: &Value, key: &str) -> Option { impl Writer<'_> { /// What `paint` writes with [`Self::color`] (a color, a spot or global color), as a string. - fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String { + pub(super) fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String { let saved = std::mem::take(&mut self.body); self.color(paint, stroke); std::mem::replace(&mut self.body, saved) @@ -267,9 +275,12 @@ impl Writer<'_> { } /// A stroke's basic filter: a color, its line style, and its width profile as the arrays - /// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`. `None` for - /// other paint. + /// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`; a stroke with a + /// brush, the brush's filter ([`Self::brush_filter`]). `None` for other paint. fn stroke_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> { + if st.brush.is_some() { + return self.brush_filter(s, st, visible); + } if !matches!(st.paint, Paint::Solid { .. }) || st.overprint { return None; } @@ -652,17 +663,19 @@ impl Writer<'_> { && let Some(text) = self.styles.known.iter().find(|(n, _)| *n == name).map(|(_, t)| t.clone()) && object_reads(orig, &text) { - self.styles.links_written += 1; return Some(name); } - if orig.appearance.effects.is_empty() || !own_style_holds(&orig.appearance) { + let brushed = brushed(orig); + if (orig.appearance.effects.is_empty() && !brushed) || !own_style_holds(&orig.appearance) { return None; } let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_))); let saved = (graded.then(|| self.gradient_mark()), self.warnings.len()); let path = drawn_outline(n, orig); let rule = object_rule(orig); - let text = self.style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule))).filter(|t| object_reads(orig, t)); + let text = self + .style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule))) + .filter(|t| object_reads(orig, t) && (!brushed || self.brushed_style_reads(t))); let Some(text) = text else { if let Some(mark) = saved.0 { self.gradient_rollback(mark); @@ -693,7 +706,8 @@ impl Writer<'_> { /// compound path's points painted with nothing, or with its gradient fill when its style has /// one; a group with its members), its name and opacity mask; then `1 (name) XW`. /// The look's group and the object itself are counted first ([`Self::take_objects`]): without - /// room for them, nothing is written. + /// room for them, nothing is written, and the object counts neither as written with its graphic + /// style nor as written with a brush on its stroke. pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) { let own = match &orig.kind { NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), @@ -704,6 +718,12 @@ impl Writer<'_> { if !self.take_objects(1 + own) { return; } + let linked = orig.extras().graphic_style.and_then(|id| self.styles.panel.get(&id)).is_some_and(|p| p == name); + if linked { + self.styles.links_written += 1; + } else if brushed(orig) { + self.brush_set.live_written += 1; + } self.state(orig.locked, !orig.visible); let xy = Xy::of(orig); let was = self.in_look; @@ -834,8 +854,12 @@ fn own_style_holds(ap: &Appearance) -> bool { ap.items.iter().filter(|i| !i.paint().is_none()).all(plain) && !ap.effects.iter().any(panel_only) } -/// Does the reader read graphic style `g` from style text `text` as it is? +/// Does the reader read graphic style `g` from style text `text` as it is? It leaves out a graphic +/// style with a brush on a stroke that paints. fn panel_reads(g: &GraphicStyle, text: &str) -> bool { + if g.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && !s.paint.is_none())) { + return false; + } let section = known_style("x", text); let Some(read) = crate::import::read_art_style(section.as_bytes(), "x", None) else { return false }; // The opacity within the written number's precision. @@ -918,7 +942,7 @@ fn normalized_params(e: &Effect) -> Value { const NEAR: f64 = 6e-3; /// JSON values equal but for numbers within the written numbers' precision. -fn near(a: &Value, b: &Value) -> bool { +pub(super) fn near(a: &Value, b: &Value) -> bool { match (a, b) { (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { (Some(x), Some(y)) => (x - y).abs() <= NEAR, diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index dc335bf1a1..b750efbc20 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -86,8 +86,8 @@ pub use ai::NameEncoding; #[cfg(test)] pub(crate) use ai::objects_read; pub(crate) use ai::{ - MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, - symbol_art_member, warp_number, + BRUSH_MANAGER, BrushesRead, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, + read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number, }; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index be2d679a78..2d6c4a533d 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -34,6 +34,7 @@ use std::io::Read as _; use super::lex::find; use crate::ps; +pub(crate) use brush::{BrushesRead, MANAGER_NAME as BRUSH_MANAGER, brushes_kept, plugin_name as brush_plugin_name, read_brush_object}; pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; #[cfg(test)] pub(crate) use envelope::MAX_CHECKED; diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index dc94ff7927..5a5db47114 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -563,3 +563,127 @@ pub(super) fn without_frame(art: Node) -> Node { } out } + +/// The plug-in name the objects of brushes of kind `kind` have, for writers. +pub(crate) fn plugin_name(kind: &BrushKind) -> &'static str { + let tool = match kind { + BrushKind::Calligraphic(_) => Tool::Calligraphic, + BrushKind::Scatter(_) => Tool::Scatter, + BrushKind::Art(_) => Tool::Art, + BrushKind::Pattern(_) => Tool::Pattern, + BrushKind::Bristle(_) => Tool::Bristle, + }; + TOOLS.iter().find(|(_, t)| *t == tool).map_or("", |(n, _)| *n) +} + +/// The plug-in name of the brush manager's order, for writers. +pub(crate) const MANAGER_NAME: &str = MANAGER; + +/// A brush object as the reader reads it, for writers that check a brush before they write it. +#[derive(Clone, Debug)] +pub(crate) struct BrushRead { + /// The brush the Brushes panel gets. + pub brush: Brush, + /// A path whose art style names the brush, at the brush's own settings, opens with the brush on + /// its stroke: a Calligraphic brush, or an Art brush whose art an Art brush draws as the file + /// does ([`art_bends`]) and whose numbers are those of a brush that stretches its art along the + /// path, unflipped, at 100% in Tints. + pub live: bool, +} + +/// The brush named `name` whose object has the plug-in name `plugin` and the parameters `params`, +/// with `art` as the art its parameters name (as the reader reads that art), as the reader reads +/// it: `None` when the reader doesn't make a brush of it. +pub(crate) fn read_brush_object(plugin: &str, name: &str, params: &str, art: Option<&Node>) -> Option { + let draft = parse_tool_object(tool(plugin)?, name, params).ok()?; + let mut sections = HashMap::new(); + if let (DraftKind::Scatter { art: section, .. } | DraftKind::Art { art: section, .. }, Some(a)) = (&draft.kind, art) { + sections.insert(section.clone(), a.clone()); + } + let drafts = [draft]; + let live = kept(&drafts, §ions).first().copied().unwrap_or(false) + && match &drafts[0].kind { + DraftKind::Calligraphic(_) => true, + DraftKind::Art { plain: true, art: section, .. } => bending_art(&drafts, §ions).contains(section), + _ => false, + }; + let [draft] = drafts; + let (mut brushes, _) = finish_brushes(vec![draft], §ions, None); + Some(BrushRead { brush: brushes.pop()?, live }) +} + +/// Which of the brush objects `brushes` (plug-in name, name, parameters, the art each names as the +/// reader reads it) the reader makes into brushes when one file holds them all: not one it can't read, +/// nor one past the art the reader copies into one file's brushes ([`kept`]). +pub(crate) fn brushes_kept(brushes: &[(&str, &str, &str, Option<&Node>)]) -> Vec { + let mut drafts = vec![]; + let mut at = vec![]; + let mut sections = HashMap::new(); + for (i, (plugin, name, params, art)) in brushes.iter().enumerate() { + let Some(d) = tool(plugin).and_then(|t| parse_tool_object(t, name, params).ok()) else { continue }; + if let (DraftKind::Scatter { art: s, .. } | DraftKind::Art { art: s, .. }, Some(a)) = (&d.kind, art) { + sections.insert(s.clone(), (*a).clone()); + } + drafts.push(d); + at.push(i); + } + let mut out = vec![false; brushes.len()]; + for (i, k) in at.into_iter().zip(kept(&drafts, §ions)) { + if let Some(slot) = out.get_mut(i) { + *slot = k; + } + } + out +} + +/// Brush objects a writer writes, as the reader reads them together from one file, for checking +/// the art styles that name them ([`Self::style_reads`]). +pub(crate) struct BrushesRead { + drafts: Vec, + /// The brush a stroke that names a brush is drawn with, by name: the first one kept + /// ([`kept`]), as an index in [`Self::drafts`]. + by_name: HashMap, + /// The art an Art brush draws as the file does ([`bending_art`]). + bending: HashSet, +} + +impl BrushesRead { + /// `brushes`: the brush objects written (plug-in name, name, parameters) and the art each names, + /// as the reader reads it. + pub(crate) fn new(brushes: &[(&str, &str, &str, Option<&Node>)]) -> Self { + let mut drafts = vec![]; + let mut sections = HashMap::new(); + for (plugin, name, params, art) in brushes { + let Some(d) = tool(plugin).and_then(|t| parse_tool_object(t, name, params).ok()) else { continue }; + if let (DraftKind::Scatter { art: s, .. } | DraftKind::Art { art: s, .. }, Some(a)) = (&d.kind, art) { + sections.insert(s.clone(), (*a).clone()); + } + drafts.push(d); + } + let mut by_name = HashMap::new(); + for (i, (d, k)) in drafts.iter().zip(kept(&drafts, §ions)).enumerate() { + if k { + by_name.entry(d.name.clone()).or_insert(i); + } + } + let bending = bending_art(&drafts, §ions); + Self { drafts, by_name, bending } + } + + /// Does the reader read every stroke of art style `style` in `section` (the text between + /// `%AI9_BeginArtStyles` and its end) that names a brush with that brush on it, at the brush's + /// own settings? A style the reader doesn't read, or that names a brush no object gives, or an + /// Art brush in a style with effects, isn't read so. + pub(crate) fn style_reads(&self, section: &[u8], style: &str) -> bool { + use super::style::StyleItem; + let styles = super::style::art_styles(section); + let Some(st) = styles.get(style).filter(|st| st.plain) else { return false }; + st.items.iter().all(|item| match item { + StyleItem::Stroke { brush: Some((name, settings)), .. } => { + let Some(draft) = self.by_name.get(name).and_then(|i| self.drafts.get(*i)) else { return false }; + draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art) && !st.effects.is_empty()) + } + _ => true, + }) + } +} diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index c0d9852d88..634c6ae9ca 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1237,8 +1237,11 @@ pub(crate) fn read_style_list(section: &[u8]) -> Vec { /// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`], /// with a style's one gradient fill as `own_gradient`, the object's own gradient, as [`styled`] reads -/// it on objects other than type. `None` when the section doesn't define it, the reader doesn't read -/// it, or it paints with a pattern, a brush or a gradient that isn't the object's own. +/// it on objects other than type. A stroke that names a brush is read with that brush's name, as +/// [`styled`] reads it on a path or compound path when the Brushes panel keeps that brush at those +/// settings, which [`crate::import::ai::brush::BrushesRead::style_reads`] checks. `None` when the section +/// doesn't define it, the reader doesn't read it, or it paints with a pattern or a gradient that +/// isn't the object's own. pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&Paint>) -> Option { use crate::import::ai::style::StyleItem; let mut st = crate::import::ai::style::art_styles(section).remove(name)?; @@ -1250,7 +1253,21 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P } } } - let appearance = appearance_from_art_style(&st)?; + // The brush names, by item; `appearance_from_art_style` reads a style's items in order. + let brushes: Vec> = st + .items + .iter_mut() + .map(|item| match item { + StyleItem::Stroke { brush, .. } => brush.take().map(|(name, _)| name), + _ => None, + }) + .collect(); + let mut appearance = appearance_from_art_style(&st)?; + for (item, brush) in appearance.items.iter_mut().zip(brushes) { + if let (AppearanceItem::Stroke(s), Some(brush)) = (item, brush) { + s.brush = Some(brush); + } + } Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder, transparency: st.transparency }) } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b99adf420e..bb8e5a2570 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -108,7 +108,17 @@ private editing data. VectorCraft: Transform, Free Distort, Pucker & Bloat, Twist, Zig Zag and Gaussian Blur too). Styles with gradient or pattern fills, with fills, strokes or effects the reader doesn't read back, with a name another style has, with names over 1,024 bytes and past 4,096 styles are left out with a warning. A panel with no style written opens with a new document's - styles, with a warning. No art style is written on a large canvas. + styles, with a warning. No art style is written on a large canvas. The Brushes panel's Calligraphic brushes, and its + Scatter and Art brushes with their art, are written in its order: Scatter and Art brushes in Tints or keeping their + art's colors, Art brushes that stretch their art along the path from left to right at 100% unflipped without + overlap, and art of paths and compound paths with points and groups with members, with one solid fill and one + plain stroke each. Other brushes, + Pattern and Bristle brushes among them, and brushes past the number or the art VectorCraft's reader reads are left + out with a warning; no brush is written on a large canvas. A path or compound path with a Calligraphic brush, or + with an Art brush in Tints whose art bends along the path, keeps the brush on its stroke; other brushed strokes are + written as their brush's art. A document that uses a new document's brushes writes none until a path keeps one on + its stroke; then the brushes are written, and the file opens without the new document's Pattern and Bristle brushes + and its Art brush with other options. Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, From 91d705e155cee50aff6c4a4738c5a653694588c3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:18:57 -0800 Subject: [PATCH 66/79] Gradients: repeat or reflect a gradient's stops a set number of times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A linear, radial, conic or diamond gradient has a spread: Pad (the default, as before), Repeat or Reflect, with a count of 1 to 100 (VectorCraft's own limit). Repeat and Reflect run the stops that many times along the vector (around the turn for a conic) and keep going past both ends, Reflect running every other run backwards, as SVG's spreadMethod does. The Gradient panel has a Spread menu and a Repeats field, and the Gradient tool marks where the runs meet. paint.setFill and paint.editGradient take `spread` and `repeats`; bad values are errors. Presets, swatches, blends, strokes along or across a path, Outline Stroke and Object › Expand follow the spread; Expand gives each run the same whole number of objects, and Outline Stroke's mesh keeps the color on each side of a sharp step between two stops at one offset. SVG writes and reads spreadMethod: SVG import now reads a repeating or reflecting gradient as one that repeats or reflects once, instead of expanding its stops over the shape. CSS writes repeating gradients. PDF writes a conic's sweep shading over one run. PDF, EPS, legacy .ai and the .ai editing data write the other kinds with their stops repeated across the shape: at most 256 runs and 16,384 stops (256 stops in .ai files), and for a radial or diamond no farther than 65,536 runs from its center, with a warning past those. PDF import now finds sharp steps in any axial or radial shading instead of blurring them. Docs: docs/mcp.md (Spread), docs/target-app-parity.md, docs/gaps.md. --- crates/color/src/gradient.rs | 182 +++++++++- crates/color/src/gradient/spread.rs | 268 ++++++++++++++ crates/color/src/gradient/spread/tests.rs | 368 ++++++++++++++++++++ crates/color/src/lib.rs | 2 +- crates/color/src/palette_io.rs | 4 +- crates/color/src/palette_io/tests.rs | 23 ++ crates/doc/src/blend.rs | 24 ++ crates/effects/src/stroke/gradient.rs | 48 ++- crates/effects/src/stroke/tests_gradient.rs | 59 ++++ crates/engine/src/cmd/expand.rs | 107 ++++-- crates/engine/src/cmd/gradient.rs | 57 ++- crates/engine/src/cmd/gradpresets.rs | 18 +- crates/engine/src/cmd/paint.rs | 2 +- crates/engine/src/cmd/swatch.rs | 11 +- crates/engine/src/tests/gradient.rs | 131 +++++++ crates/engine/src/tests/objexpand.rs | 44 +++ crates/eps/src/ai_editing/tests.rs | 141 ++++++++ crates/eps/src/ai_editing/writer.rs | 4 +- crates/eps/src/ai_editing/writer/paints.rs | 60 +++- crates/eps/src/legacy_ai.rs | 2 + crates/eps/src/legacy_ai/encoding.rs | 8 +- crates/eps/src/legacy_ai/tests.rs | 139 ++++++++ crates/eps/src/legacy_ai/writer.rs | 23 +- crates/eps/src/ps.rs | 32 +- crates/eps/src/scene.rs | 46 ++- crates/eps/src/tests.rs | 76 ++++ crates/metafile/src/scene.rs | 4 +- crates/pdf/src/export/nodes.rs | 13 +- crates/pdf/src/export/paint.rs | 114 +++++- crates/pdf/src/import_shading.rs | 48 ++- crates/pdf/src/tests.rs | 1 + crates/pdf/src/tests/spread.rs | 260 ++++++++++++++ crates/plugins/src/objects.rs | 1 + crates/render/src/dither.rs | 2 +- crates/render/src/paint.rs | 18 +- crates/render/src/renderer/paint.rs | 3 +- crates/render/src/tests/strokegradient.rs | 12 + crates/render/tests/it/main.rs | 1 + crates/render/tests/it/spread.rs | 93 +++++ crates/svg/src/css.rs | 26 +- crates/svg/src/export/paint.rs | 19 +- crates/svg/src/import.rs | 1 - crates/svg/src/import/importer.rs | 53 +-- crates/svg/src/import/spread.rs | 154 -------- crates/svg/src/lib.rs | 4 +- crates/svg/tests/it/css_rules.rs | 39 +++ crates/svg/tests/it/main.rs | 1 + crates/svg/tests/it/spread.rs | 132 +++++++ crates/tools/src/params.rs | 8 + crates/tools/src/xform/eyedropper.rs | 2 +- crates/tools/src/xform/gradient.rs | 28 ++ crates/tools/src/xform/gradient/tests.rs | 33 ++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/panels/gradient.rs | 38 +- crates/ui-egui/src/panels/gradient/tests.rs | 38 ++ crates/ui-egui/src/widgets/chips.rs | 2 +- docs/gaps.md | 6 +- docs/mcp.md | 37 +- docs/target-app-parity.md | 1 + 85 files changed, 2874 insertions(+), 317 deletions(-) create mode 100644 crates/color/src/gradient/spread.rs create mode 100644 crates/color/src/gradient/spread/tests.rs create mode 100644 crates/pdf/src/tests/spread.rs create mode 100644 crates/render/tests/it/spread.rs delete mode 100644 crates/svg/src/import/spread.rs create mode 100644 crates/svg/tests/it/spread.rs diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index c5803e3224..e49478eab7 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -8,6 +8,9 @@ use serde::{Deserialize, Serialize}; use crate::Color; use crate::freeform::Freeform; +mod spread; +pub use spread::{MAX_SPREAD_LEVEL, MAX_SPREAD_RUNS, MAX_SPREAD_STOPS, Padded}; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum GradientKind { #[default] @@ -94,6 +97,82 @@ impl GradientMethod { } } +/// What a linear, radial, conic or diamond gradient paints past the ends of its stops (the +/// Gradient panel's Spread). With Repeat and Reflect, the stops run [`Gradient::repeats`] times +/// along the vector (around the circle, for a conic), and keep going past both of its ends. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize)] +pub enum GradientSpread { + /// The first and last stops' colors carry on past the ends, as gradients always did. + #[default] + Pad, + /// The stops start again at each end. + Repeat, + /// The stops run back the other way at each end, so every other run is mirrored. + Reflect, +} + +impl GradientSpread { + /// Every spread, in the order the Gradient panel lists them. + pub const ALL: [GradientSpread; 3] = [GradientSpread::Pad, GradientSpread::Repeat, GradientSpread::Reflect]; + /// Parse a spread name (`pad`, `repeat`, `reflect`; any case). + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) + } + pub fn label(self) -> &'static str { + match self { + Self::Pad => "Pad", + Self::Repeat => "Repeat", + Self::Reflect => "Reflect", + } + } + pub fn is_pad(&self) -> bool { + *self == Self::Pad + } +} + +impl<'de> Deserialize<'de> for GradientSpread { + /// A spread name; a file's unknown name or other value reads as Pad. + fn deserialize>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Raw { + Name(String), + Other(serde::de::IgnoredAny), + } + Ok(match Raw::deserialize(d)? { + Raw::Name(s) => Self::parse(&s).unwrap_or_default(), + Raw::Other(_) => Self::Pad, + }) + } +} + +/// Most times a gradient's stops run along its vector ([`Gradient::repeats`]): VectorCraft's own +/// limit. +pub const MAX_REPEATS: u32 = 100; + +fn one_repeat() -> u32 { + 1 +} + +fn is_one_repeat(n: &u32) -> bool { + *n == 1 +} + +/// A file's repeat count: a number, rounded and kept within 1 to [`MAX_REPEATS`]; anything else +/// reads as 1. +fn lenient_repeats<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Raw { + Number(f64), + Other(serde::de::IgnoredAny), + } + Ok(match Raw::deserialize(d)? { + Raw::Number(n) if n.is_finite() => n.round().clamp(1.0, f64::from(MAX_REPEATS)) as u32, + _ => 1, + }) +} + /// How far (display RGB, 0..1) the straight line between a Perceptual gradient's neighbouring /// expanded stops may stray from the perceptual mix: under half a colour level. const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0; @@ -259,6 +338,15 @@ pub struct Gradient { /// How the colours between the stops are mixed (Classic unless set; written only when not). #[serde(default, skip_serializing_if = "GradientMethod::is_classic")] pub method: GradientMethod, + /// What it paints past the ends of its stops (Pad unless set; written only when not). A + /// freeform gradient has no ends and ignores it. + #[serde(default, skip_serializing_if = "GradientSpread::is_pad")] + pub spread: GradientSpread, + /// How many times the stops run along the vector with Repeat and Reflect, 1 to + /// [`MAX_REPEATS`] (use [`Self::repeat_count`]: a value set in code may be out of range); Pad + /// ignores it. Written only when it is not 1. + #[serde(default = "one_repeat", skip_serializing_if = "is_one_repeat", deserialize_with = "lenient_repeats")] + pub repeats: u32, } impl Default for Gradient { @@ -271,7 +359,55 @@ impl Default for Gradient { impl Gradient { /// A gradient of `kind` through `stops`, with the default options. pub fn new(kind: GradientKind, stops: Vec) -> Self { - Self { kind, stops, dither: false, method: GradientMethod::Classic } + Self { kind, stops, dither: false, method: GradientMethod::Classic, spread: GradientSpread::Pad, repeats: 1 } + } + /// [`Self::repeats`] kept within 1 to [`MAX_REPEATS`]. + pub fn repeat_count(&self) -> u32 { + self.repeats.clamp(1, MAX_REPEATS) + } + /// Does it paint something other than its end colors past the ends of its stops, or run its + /// stops more than once: a linear, radial, conic or diamond gradient that repeats or reflects? + pub fn is_spread(&self) -> bool { + !self.spread.is_pad() && self.kind != GradientKind::Freeform + } + /// The spread and count as the gradient paints them: Pad and 1 for one that pads (whatever + /// count it keeps) and for a freeform one, else its spread and [`Self::repeat_count`]. Equal + /// for two gradients whose spreads paint alike. + pub fn painted_spread(&self) -> (GradientSpread, u32) { + if self.is_spread() { (self.spread, self.repeat_count()) } else { (GradientSpread::Pad, 1) } + } + /// The position (0..1) along the stops that gradient parameter `t` (0 at the start of the + /// vector, 1 at its end; [`GradientGeom::param_at`]) samples: `t` itself with Pad (the stops + /// clamp it), else `t` × [`Self::repeat_count`] wrapped back into 0..1, every other run + /// mirrored with Reflect. A `t` that is not finite is returned as it is. + pub fn spread_param(&self, t: f64) -> f64 { + self.spreader()(t) + } + /// How many wedges a conic gradient is written as ([`GradientGeom::conic_wedges`]) where + /// `per_turn` would do for stops that run once around: that many for each time the stops run + /// around, at most [`MAX_CONIC_WEDGES`]. Sample each wedge at [`Self::spread_param`] of its + /// turn. + pub fn conic_wedge_count(&self, per_turn: usize) -> usize { + let runs = if self.is_spread() { self.repeat_count() as usize } else { 1 }; + per_turn.saturating_mul(runs).clamp(1, MAX_CONIC_WEDGES) + } + /// [`Self::spread_param`] as a function that holds no borrow of the gradient. + pub fn spreader(&self) -> impl Fn(f64) -> f64 + Copy + use<> { + let spread = if self.is_spread() { self.spread } else { GradientSpread::Pad }; + let n = f64::from(self.repeat_count()); + move |t: f64| { + if !t.is_finite() { + return t; + } + match spread { + GradientSpread::Pad => t, + GradientSpread::Repeat => (t * n).rem_euclid(1.0), + GradientSpread::Reflect => { + let w = (t * n).rem_euclid(2.0); + if w > 1.0 { 2.0 - w } else { w } + } + } + } } /// Colour and opacity at `t` (honours midpoints and the method). pub fn sample(&self, t: f32) -> (Color, f32) { @@ -544,10 +680,18 @@ impl Gradient { GradientStop { opacity, ..GradientStop::new(offset, color) } }; let d = to - from; - let mut stops = vec![stop(0.0, from)]; + let expanded = self.expanded_stops(); + // Where stops share the offset `from` (a sharp step, as where the runs of a repeating + // gradient meet), the piece starts with the color after the step; sampling gives the one + // before it. + let first = match expanded.iter().rev().find(|(t, ..)| *t == from) { + Some(&(_, color, opacity)) if d > 1e-6 => GradientStop { opacity, ..GradientStop::new(0.0, color) }, + _ => stop(0.0, from), + }; + let mut stops = vec![first]; if d > 1e-6 { stops.extend( - self.expanded_stops() + expanded .into_iter() .filter(|(t, ..)| *t > from && *t < to) .map(|(t, color, opacity)| GradientStop { opacity, ..GradientStop::new((t - from) / d, color) }), @@ -954,6 +1098,35 @@ impl GradientGeom { self.set_focal(Some(m * q)); } } + /// The placement whose level `t` is this one's level `k`·`t` (`k` > 0), for a linear, radial or + /// diamond gradient: the vector `k` times as long from the same start, and for an off-center + /// radial the extent ellipse scaled `k` times about the focal point, which stays. A conic keeps + /// its placement (its level is a turn). + pub fn levels_scaled(&self, kind: GradientKind, k: f64) -> GradientGeom { + if kind == GradientKind::Conic || !(k.is_finite() && k > 0.0) { + return *self; + } + let u = self.end - self.start; + let mut out = *self; + if kind == GradientKind::Radial + && let (Some(f), Some(frame)) = (self.focal, self.unit_frame()) + { + // Level t of an off-center radial is the extent ellipse scaled by t about the line + // from the focal point to the center: the new center sits on that line, `k` times as + // far from the focal point. + let f = (frame.inverse() * f).to_vec2(); + out.start = frame * (f * (1.0 - k)).to_point(); + } + out.end = out.start + u * k; + out + } + + /// The placement over which one run of the stops of a gradient that repeats `n` times along + /// its vector lies ([`Self::levels_scaled`] by 1/`n`). + pub fn period(&self, kind: GradientKind, n: u32) -> GradientGeom { + self.levels_scaled(kind, 1.0 / f64::from(n.max(1))) + } + pub fn angle_deg(&self) -> f64 { let v = self.end - self.start; (-v.y).atan2(v.x).to_degrees() @@ -1148,12 +1321,13 @@ impl GradientPaint { inks: bool, ) -> impl Iterator + use { let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), fill && self.gradient.dither); + let spread = self.gradient.spreader(); let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1))); let (x0, y0) = (area.x0, area.y0); let [across, down] = cells.map(|r| u32::from(r.start)..u32::from(r.end)); down.flat_map(move |j| across.clone().map(move |i| (i, j))).map(move |(i, j)| { let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch); - let c = ramp.at(geom.param_at(kind, p) as f32); + let c = ramp.at(spread(geom.param_at(kind, p)) as f32); std::array::from_fn(|k| { if k + 1 == N { return (c[k].clamp(0.0, 1.0) * 255.0).round() as u8; diff --git a/crates/color/src/gradient/spread.rs b/crates/color/src/gradient/spread.rs new file mode 100644 index 0000000000..e449151d89 --- /dev/null +++ b/crates/color/src/gradient/spread.rs @@ -0,0 +1,268 @@ +//! A repeating or reflecting gradient as one that pads, for formats whose gradients only pad +//! past their ends: the stops repeated (every other run mirrored, for Reflect) as many times as +//! the painted area needs, and the vector stretched over those runs. + +use kurbo::{Point, Rect, Vec2}; + +use super::{Gradient, GradientGeom, GradientKind, GradientSpread, GradientStop}; + +/// Most runs of the stops [`Gradient::padded_over`] writes. +pub const MAX_SPREAD_RUNS: usize = 256; + +/// Most stops [`Gradient::padded_over`] writes, all runs together. +pub const MAX_SPREAD_STOPS: usize = 16384; + +/// The farthest level from its center a radial or diamond gradient's runs are written out to by +/// [`Gradient::padded_over`] (its stops' offsets, single-precision fractions of it, stay apart), +/// nearer for stops closer together than a run ([`MIN_OFFSET_GAP`]). +pub const MAX_SPREAD_LEVEL: f64 = 65536.0; + +/// The least difference between two stops' offsets [`Gradient::padded_over`] writes as +/// different: sixteen times single precision's step just below 1. +const MIN_OFFSET_GAP: f64 = 1e-6; + +/// A repeating or reflecting gradient written as one that pads ([`Gradient::padded_over`]). +#[derive(Clone, Debug, PartialEq)] +pub struct Padded { + /// The gradient with its stops repeated, padding past its ends. + pub gradient: Gradient, + /// Where those runs lie. + pub geom: GradientGeom, + /// Where along `geom` (0 to 1) the first run written starts: 0 for a linear gradient, the + /// first run's level over the last one's outer level for a radial or diamond one. + pub first: f64, + /// How many runs are written, evenly spaced from [`Self::first`] to 1. + pub runs: usize, + /// The area needed more runs than [`MAX_SPREAD_RUNS`] (or than [`MAX_SPREAD_STOPS`] allows), + /// or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written. + pub capped: bool, +} + +impl Gradient { + /// A linear, radial or diamond gradient that repeats or reflects, placed at `geom`, as a + /// gradient that pads and paints `area` the same way: its stops repeated, each run's + /// placement [`GradientGeom::period`] of `geom`, as many runs as the area reaches (a radial or + /// diamond from its first level, 0, the first stop's color inside the area's innermost run). + /// `None` for a gradient that pads, a conic or + /// freeform one, one without stops, and a placement whose levels at the corners are not + /// finite. + pub fn padded_over(&self, geom: &GradientGeom, area: Rect) -> Option { + self.padded_within(geom, area, MAX_SPREAD_STOPS) + } + + /// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). + pub fn padded_within(&self, geom: &GradientGeom, area: Rect, max_stops: usize) -> Option { + let kind = self.kind; + if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() { + return None; + } + let period = geom.period(kind, self.repeat_count()); + let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)]; + let ts = corners.map(|p| period.param_at(kind, p)); + if ts.iter().any(|t| !t.is_finite()) { + return None; + } + let centered = kind.is_centered(); + // The level's range over the area. A linear gradient's extremes lie at corners. Radial and + // diamond levels are nested convex rings from level 0: the largest at a corner, the + // smallest 0 when the area holds the rings' center, else on its edge. + let lo = if centered { inner_level(&period, kind, area).floor() } else { ts.iter().copied().fold(f64::INFINITY, f64::min).floor() }; + let hi = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max).ceil().max(lo + 1.0); + let one = whole_run(&self.stops); + let most = MAX_SPREAD_RUNS.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1); + let capped = hi - lo > most as f64; + // Capped, the runs kept are the area's innermost ones (radial and diamond), or (linear) + // those around the vector's start where the area reaches it, else the area's runs nearest + // to it. + let lo = match capped { + true if !centered => (-((most / 2) as f64)).clamp(lo, hi - most as f64), + _ => lo, + }; + let n = (hi - lo).min(most as f64).max(1.0); + // A radial or diamond writes its runs among the levels from 0, which its stops' offsets + // (single-precision fractions of the outermost level) tell apart only so far out, the + // nearer the closer its stops lie: past that, the runs end there and the gradient pads. + let gap = one.windows(2).map(|w| f64::from(w[1].offset - w[0].offset)).filter(|d| *d > 0.0).fold(1.0, f64::min); + // A whole level, so the runs keep their places (and Reflect its mirrored ones). + let farthest = MAX_SPREAD_LEVEL.min(gap / MIN_OFFSET_GAP).floor().max(n); + let far = centered && lo + n > farthest; + let (lo, capped) = if far { ((farthest - n).max(0.0), true) } else { (lo, capped) }; + // `lo` is a whole number, possibly past what an integer holds: its parity in floating point. + let first_odd = lo.rem_euclid(2.0) >= 1.0; + let (stops, geom) = if centered { + // The levels from 0, the runs from level `lo` (the first stop's color inside it). + (self.runs(&one, first_odd, lo, n as usize, lo + n), period.levels_scaled(kind, lo + n)) + } else { + let u = period.end - period.start; + ( + self.runs(&one, first_odd, 0.0, n as usize, n), + GradientGeom { start: period.start + u * lo, end: period.start + u * (lo + n), ..period }, + ) + }; + let gradient = Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }; + let first = if centered { lo / (lo + n) } else { 0.0 }; + Some(Padded { gradient, geom, first, runs: n as usize, capped }) + } + + /// The gradient as it runs from the start of its vector (0) to its end (1), as one that + /// pads: a gradient that repeats or reflects with its stops repeated [`Self::repeat_count`] + /// times between 0 and 1 (every other run mirrored, for Reflect), for what only shows that + /// stretch (a gradient along or across a stroke, a swatch's preview). Others as they are. + pub fn unrolled(&self) -> std::borrow::Cow<'_, Gradient> { + self.unrolled_within(MAX_SPREAD_STOPS) + } + + /// [`Self::unrolled`] with at most `max_stops` stops (and at least one run): past the runs + /// that fit, the last one pads. + pub fn unrolled_within(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> { + use std::borrow::Cow; + if !self.is_spread() { + return Cow::Borrowed(self); + } + // Without stops there is nothing to repeat: it pads all the same. + if self.stops.is_empty() { + return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() }); + } + let one = whole_run(&self.stops); + let n = self.repeat_count() as usize; + let kept = n.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1); + let stops = self.runs(&one, false, 0.0, kept, n as f64); + Cow::Owned(Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }) + } + + /// One cycle of a gradient that repeats or reflects, which repeated end to end paints it, as + /// a gradient that pads: one run of the stops (Repeat), or a run and its mirror (Reflect), + /// between 0 and 1, and how many runs that is (1 or 2). `None` for a gradient that pads and + /// one without stops. + pub fn cycle(&self) -> Option<(Gradient, u32)> { + if !self.is_spread() || self.stops.is_empty() { + return None; + } + let one = whole_run(&self.stops); + let runs = if self.spread == GradientSpread::Reflect { 2 } else { 1 }; + let stops = self.runs(&one, false, 0.0, runs, runs as f64); + Some((Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }, runs as u32)) + } + + /// `n` runs of `one` (one run of the stops, [`whole_run`]) on a line of `of` runs from 0 to 1, + /// the first of them `start` runs along it and an odd-numbered run when `first_odd` (with + /// Reflect, the odd-numbered runs are mirrored). + fn runs(&self, one: &[GradientStop], first_odd: bool, start: f64, n: usize, of: f64) -> Vec { + let mirrored_run = (self.spread == GradientSpread::Reflect).then(|| mirror(one)); + let mut stops = Vec::with_capacity(one.len() * n); + for j in 0..n { + let run = match &mirrored_run { + Some(m) if (j % 2 == 1) != first_odd => m.as_slice(), + _ => one, + }; + stops.extend(run.iter().map(|s| GradientStop { offset: ((start + j as f64 + f64::from(s.offset)) / of) as f32, ..s.clone() })); + } + stops + } +} + +/// The smallest level of radial or diamond gradient `geom` over `area`, less a run (for rounding): +/// 0 when the area holds the rings' center (a radial's focal point, a diamond's center), else the +/// smallest along its sides, worked out in the gradient's unit frame from the sides' ends rather +/// than from samples along them, so a long side does not hide a run near it. 0 for a placement +/// without a frame. +fn inner_level(geom: &GradientGeom, kind: GradientKind, area: Rect) -> f64 { + let center = if kind == GradientKind::Radial { geom.focal_point() } else { geom.start }; + if area.contains(center) { + return 0.0; + } + let frame = if kind == GradientKind::Diamond { geom.diamond_frame() } else { geom.unit_frame() }; + let Some(to_unit) = frame.map(|m| m.inverse()) else { return 0.0 }; + let focal = geom.focal.filter(|_| kind == GradientKind::Radial).map(|f| (to_unit * f).to_vec2()); + let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)] + .map(|p| (to_unit * p).to_vec2()); + let mut least = f64::INFINITY; + for (&a, &b) in corners.iter().zip(corners.iter().cycle().skip(1)) { + let side = if kind == GradientKind::Diamond { diamond_least(a, b) } else { radial_least(a, b, focal) }; + least = least.min(side); + } + if least.is_finite() { (least - 1.0).max(0.0) } else { 0.0 } +} + +/// The smallest diamond level, |x| + |y| in its unit frame, on the segment from `a` to `b`: the +/// level is linear between the points where the segment crosses an axis, so the smallest is at +/// an end or at one of those crossings. +fn diamond_least(a: Vec2, b: Vec2) -> f64 { + let d = b - a; + let crossings = [(d.x != 0.0).then(|| -a.x / d.x), (d.y != 0.0).then(|| -a.y / d.y)]; + [Some(0.0), Some(1.0), crossings[0], crossings[1]] + .into_iter() + .flatten() + .filter(|u| (0.0..=1.0).contains(u)) + .map(|u| { + let p = a + d * u; + p.x.abs() + p.y.abs() + }) + .fold(f64::INFINITY, f64::min) +} + +/// Halvings at most in [`radial_least`]: enough to bring any finite span below a quarter level. +const RADIAL_HALVINGS: usize = 1100; + +/// The smallest radial level on the segment from `a` to `b` in the unit frame, at most a quarter +/// level under it: the distance to the center for a centered radial. An off-center radial's level +/// `t` is the circle of radius `t` about `f`(1 − `t`), `f` its focal point; the segment's distance +/// from that center less `t` falls as `t` grows (the center moves |`f`| < 1 per level), and the +/// smallest level is where it reaches 0, found by halving. 0 for a focal point not inside the +/// extent ellipse. +fn radial_least(a: Vec2, b: Vec2, focal: Option) -> f64 { + let Some(f) = focal else { return distance_to_segment(a, b, Vec2::ZERO) }; + let reach = f.hypot(); + if reach.is_nan() || reach >= 1.0 { + return 0.0; + } + let past = |t: f64| distance_to_segment(a, b, f * (1.0 - t)) <= t; + // At this level the circle reaches the segment's nearest point to the ellipse's center. + let mut hi = (distance_to_segment(a, b, Vec2::ZERO) / (1.0 - reach)).max(1.0); + let mut lo = 0.0; + if !hi.is_finite() || past(lo) { + return 0.0; + } + for _ in 0..RADIAL_HALVINGS { + let mid = lo + (hi - lo) / 2.0; + if hi - lo <= 0.25 || mid <= lo || mid >= hi { + break; + } + if past(mid) { hi = mid } else { lo = mid } + } + lo +} + +/// How far `c` is from the segment from `a` to `b`. +fn distance_to_segment(a: Vec2, b: Vec2, c: Vec2) -> f64 { + let d = b - a; + let along = (c - a).dot(d) / d.hypot2(); + let u = if along.is_finite() { along.clamp(0.0, 1.0) } else { 0.0 }; + (a + d * u - c).hypot() +} + +/// The stops of one run, from offset 0 to 1: the end colors held out to the ends. +fn whole_run(stops: &[GradientStop]) -> Vec { + let mut out = stops.to_vec(); + if let Some(first) = out.first().filter(|s| s.offset > 0.0).cloned() { + out.insert(0, GradientStop { offset: 0.0, ..first }); + } + if let Some(last) = out.last_mut() { + // The last stop's midpoint leads nowhere; the copy at 1 takes over. + if last.offset < 1.0 { + let end = GradientStop { offset: 1.0, ..last.clone() }; + out.push(end); + } + } + out +} + +/// A run mirrored: the stops in reverse, each midpoint measured from the other side. +fn mirror(run: &[GradientStop]) -> Vec { + let mut g = Gradient::new(GradientKind::Linear, run.to_vec()); + g.reverse(); + g.stops +} + +#[cfg(test)] +mod tests; diff --git a/crates/color/src/gradient/spread/tests.rs b/crates/color/src/gradient/spread/tests.rs new file mode 100644 index 0000000000..2465039ca5 --- /dev/null +++ b/crates/color/src/gradient/spread/tests.rs @@ -0,0 +1,368 @@ +use kurbo::Vec2; + +use super::*; +use crate::gradient::{GradientPaint, MAX_REPEATS}; +use crate::{Color, GradientMethod}; + +fn spread(kind: GradientKind, spread: GradientSpread, repeats: u32) -> Gradient { + Gradient { spread, repeats, ..Gradient::new(kind, Gradient::default().stops) } +} + +fn black_white() -> Vec { + let mut a = GradientStop::new(0.0, Color::BLACK); + a.midpoint = 0.3; + vec![a, GradientStop::new(1.0, Color::WHITE)] +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +/// Repeat wraps the parameter, `repeats` times along the vector and on past both ends; Reflect +/// runs every other run backwards; Pad leaves it to the stops to clamp; a freeform gradient has +/// no ends to spread past. +#[test] +fn spread_maps_the_parameter_to_a_place_along_the_stops() { + let repeat = spread(GradientKind::Linear, GradientSpread::Repeat, 3); + for (t, want) in [(0.0, 0.0), (0.1, 0.3), (0.5, 0.5), (1.0 / 3.0 + 0.1, 0.3), (1.1, 0.3), (-0.1, 0.7)] { + assert!(close(repeat.spread_param(t), want), "repeat {t}: {}", repeat.spread_param(t)); + } + let reflect = spread(GradientKind::Radial, GradientSpread::Reflect, 3); + for (t, want) in [(0.0, 0.0), (0.1, 0.3), (1.0 / 3.0 + 0.1, 0.7), (2.0 / 3.0 + 0.1, 0.3), (1.0, 1.0), (-0.1, 0.3)] { + assert!(close(reflect.spread_param(t), want), "reflect {t}: {}", reflect.spread_param(t)); + } + let pad = spread(GradientKind::Linear, GradientSpread::Pad, 3); + assert_eq!((pad.spread_param(1.7), pad.spread_param(-0.2)), (1.7, -0.2)); + let free = spread(GradientKind::Freeform, GradientSpread::Repeat, 3); + assert_eq!(free.spread_param(0.7), 0.7); + assert!(!free.is_spread() && !pad.is_spread() && repeat.is_spread()); + assert!(repeat.spread_param(f64::NAN).is_nan()); + // A count out of range (set in code) counts as the nearest one in range. + assert_eq!(spread(GradientKind::Linear, GradientSpread::Repeat, 0).repeat_count(), 1); + assert_eq!(spread(GradientKind::Linear, GradientSpread::Repeat, 5000).repeat_count(), MAX_REPEATS); +} + +/// The native format writes the spread and count only when they are not Pad and 1, reads a file +/// without them as Pad and 1, and reads an unknown spread or a count out of range as the nearest +/// one it can hold. +#[test] +fn the_file_keeps_the_spread_and_reads_bad_values_leniently() { + let plain = serde_json::to_value(Gradient::default()).unwrap(); + assert!(plain.get("spread").is_none() && plain.get("repeats").is_none(), "{plain}"); + let g = spread(GradientKind::Conic, GradientSpread::Reflect, 4); + let v = serde_json::to_value(&g).unwrap(); + assert_eq!((v["spread"].as_str(), v["repeats"].as_u64()), (Some("Reflect"), Some(4))); + assert_eq!(serde_json::from_value::(v).unwrap(), g); + let read = |spread: serde_json::Value, repeats: serde_json::Value| { + let mut v = serde_json::to_value(Gradient::default()).unwrap(); + v["spread"] = spread; + v["repeats"] = repeats; + let g: Gradient = serde_json::from_value(v).unwrap(); + (g.spread, g.repeats) + }; + assert_eq!(read("Repeat".into(), 3.4.into()), (GradientSpread::Repeat, 3)); + assert_eq!(read("Spiral".into(), (-5).into()), (GradientSpread::Pad, 1)); + assert_eq!(read(7.into(), 1e12.into()), (GradientSpread::Pad, MAX_REPEATS)); + assert_eq!(read(serde_json::Value::Null, "many".into()), (GradientSpread::Pad, 1)); + let missing: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap(); + assert_eq!((missing.spread, missing.repeats), (GradientSpread::Pad, 1)); +} + +/// One run's placement: a level of the period is the gradient's level times the count, for +/// linear, diamond and off-center radial gradients; a conic keeps its placement. +#[test] +fn a_period_holds_one_run_of_the_stops() { + let p = Point::new(72.0, 61.0); + let linear = GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(60.0, 40.0), aspect: 1.0, focal: None }; + let radial = GradientGeom { start: Point::new(50.0, 50.0), end: Point::new(60.0, 50.0), aspect: 0.6, focal: Some(Point::new(55.0, 52.0)) }; + let diamond = GradientGeom { end: Point::new(58.0, 56.0), focal: None, ..radial }; + for (kind, geom) in [(GradientKind::Linear, linear), (GradientKind::Radial, radial), (GradientKind::Diamond, diamond)] { + let period = geom.period(kind, 4); + assert!(close(period.param_at(kind, p), 4.0 * geom.param_at(kind, p)), "{kind:?}"); + assert_eq!(period.focal, geom.focal, "{kind:?}"); + } + assert_eq!(radial.period(GradientKind::Conic, 4), radial); + assert_eq!(linear.period(GradientKind::Linear, 0), linear, "a count of 0 counts as 1"); +} + +/// The stops repeated over the area: as many runs as its corners reach, each in place. +#[test] +fn a_linear_gradient_pads_with_its_stops_repeated_over_the_area() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(20.0, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, black_white()) }; + let p = g.padded_over(&geom, Rect::new(-5.0, 0.0, 25.0, 10.0)).unwrap(); + assert!(!p.capped); + // Runs of 10 from -10 to 30: four of them. + assert_eq!((p.geom.start, p.geom.end), (Point::new(-10.0, 0.0), Point::new(30.0, 0.0))); + let offsets: Vec = p.gradient.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0]); + assert!(p.gradient.stops.iter().step_by(2).all(|s| s.color == Color::BLACK && (s.midpoint - 0.3).abs() < 1e-6)); + assert_eq!((p.gradient.spread, p.gradient.repeats), (GradientSpread::Pad, 1)); + // Every point gets the color the gradient gives it. + for x in [-4.0, 1.0, 7.5, 13.0, 24.0] { + let q = Point::new(x, 5.0); + let want = g.sample(g.spread_param(geom.param_at(GradientKind::Linear, q)) as f32).0; + let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0; + assert!(want.to_rgb().iter().zip(got.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-4), "{x}: {want:?} vs {got:?}"); + } +} + +#[test] +fn reflect_mirrors_every_other_run_and_its_midpoints() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Reflect, ..Gradient::new(GradientKind::Linear, black_white()) }; + let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 20.0, 10.0)).unwrap(); + let colors: Vec = p.gradient.stops.iter().map(|s| s.color).collect(); + assert_eq!(colors, [Color::BLACK, Color::WHITE, Color::WHITE, Color::BLACK]); + // Black → white with the midpoint at 0.3 mirrors to white → black with it at 0.7. + assert!((p.gradient.stops[2].midpoint - 0.7).abs() < 1e-6, "{:?}", p.gradient.stops); +} + +#[test] +fn stops_inside_a_run_hold_their_colors_to_its_ends() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; + let stops = vec![GradientStop::new(0.25, Color::BLACK), GradientStop::new(0.75, Color::WHITE)]; + let g = Gradient { spread: GradientSpread::Repeat, ..Gradient::new(GradientKind::Linear, stops) }; + let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 20.0, 10.0)).unwrap(); + let offsets: Vec = p.gradient.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.125, 0.375, 0.5, 0.5, 0.625, 0.875, 1.0]); +} + +/// An off-center radial grows about its focal point, a diamond about its center; every point +/// keeps its color. +#[test] +fn radial_and_diamond_gradients_pad_in_rings_from_their_first_level() { + let c = Point::new(50.0, 50.0); + let radial = GradientGeom { start: c, end: Point::new(60.0, 50.0), aspect: 1.0, focal: Some(Point::new(55.0, 50.0)) }; + let diamond = GradientGeom { focal: None, ..radial }; + for (kind, geom) in [(GradientKind::Radial, radial), (GradientKind::Diamond, diamond)] { + let g = Gradient { spread: GradientSpread::Reflect, repeats: 2, ..Gradient::new(kind, black_white()) }; + let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 100.0, 100.0)).unwrap(); + assert!(p.gradient.stops.len() / 2 >= 10, "{kind:?}: {}", p.gradient.stops.len()); + assert_eq!(p.geom.focal, geom.focal); + for q in [Point::new(72.0, 61.0), Point::new(10.0, 95.0), Point::new(52.0, 49.0)] { + let want = g.sample(g.spread_param(geom.param_at(kind, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(kind, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{kind:?} {q:?}: {want:?} vs {got:?}"); + } + } +} + +/// A run of the stops far too short for the area, or a gradient of many stops, is written with at +/// most so many runs and stops, and pads past them; a conic, a padding gradient and one without +/// stops have nothing to pad. +#[test] +fn far_repeats_and_many_stops_are_capped() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, black_white()) }; + let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap(); + assert!(p.capped); + assert_eq!(p.gradient.stops.len(), 2 * MAX_SPREAD_RUNS); + let many: Vec = (0..=4000).map(|i| GradientStop::new(i as f32 / 4000.0, Color::gray(i as f32 / 4000.0))).collect(); + let g = Gradient { spread: GradientSpread::Reflect, repeats: 50, ..Gradient::new(GradientKind::Linear, many) }; + let wide = GradientGeom { end: Point::new(100.0, 0.0), ..geom }; + let p = g.padded_over(&wide, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap(); + assert!(p.capped && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{}", p.gradient.stops.len()); + assert!(g.unrolled().stops.len() <= MAX_SPREAD_STOPS); + assert!(p.gradient.padded_over(&p.geom, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none(), "it pads"); + assert!(spread(GradientKind::Conic, GradientSpread::Repeat, 3).padded_over(&wide, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none()); + let empty = Gradient { spread: GradientSpread::Repeat, ..Gradient::new(GradientKind::Linear, vec![]) }; + assert!(empty.padded_over(&wide, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none()); +} + +/// The runs written are the ones the area shows: a radial or diamond centered a thousand runs +/// from the area writes the hundred it crosses, not the first 256 from the center; a linear +/// gradient over an area of 1,100 runs far before its start, capped, keeps the runs nearest it. +/// Every point checked gets the color the gradient gives it. +#[test] +fn the_runs_written_cover_a_distant_area() { + let centered = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(1.0, 50.0), aspect: 1.0, focal: None }; + let before = GradientGeom { start: Point::new(5000.0, 0.0), end: Point::new(5001.0, 0.0), aspect: 1.0, focal: None }; + for (kind, geom, area) in [ + (GradientKind::Radial, centered, Rect::new(1000.0, 0.0, 1100.0, 100.0)), + (GradientKind::Diamond, centered, Rect::new(1000.0, 0.0, 1100.0, 100.0)), + (GradientKind::Linear, before, Rect::new(0.0, 0.0, 1100.0, 100.0)), + ] { + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(kind, black_white()) }; + let p = g.padded_over(&geom, area).unwrap(); + assert_eq!(p.capped, kind == GradientKind::Linear, "{kind:?}"); + assert!(p.gradient.stops.len() <= MAX_SPREAD_STOPS); + for q in [Point::new(1000.5, 0.5), Point::new(1050.25, 50.0), Point::new(1099.5, 99.5), Point::new(1010.75, 90.0)] { + let want = g.sample(g.spread_param(geom.param_at(kind, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(kind, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{kind:?} {q:?}: {want:?} vs {got:?}"); + } + } +} + +/// A tiny radial centered just outside the area, beside a side between any evenly spaced +/// samples of it: the runs written start from the area's true innermost level. +#[test] +fn the_innermost_level_is_found_between_samples() { + let c = Point::new(-0.0005, 7.8125); + let geom = GradientGeom { start: c, end: c + Vec2::new(0.001, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(GradientKind::Radial, black_white()) }; + let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 1000.0, 1000.0)).unwrap(); + for q in [Point::new(0.05, 7.8125), Point::new(0.1, 7.9)] { + let want = g.sample(g.spread_param(geom.param_at(GradientKind::Radial, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(GradientKind::Radial, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{q:?}: {want:?} vs {got:?}"); + } +} + +/// An area four billion units wide whose long side passes 50 runs from a tiny radial or diamond's +/// center: the runs written start from that side's true innermost level, so the runs beside it +/// keep their colors. +#[test] +fn the_innermost_level_is_found_along_a_very_long_side() { + let area = Rect::new(-1.3e9, 0.0, 2.9e9, 1.0); + for x in [0.3, 12345.678, -7777.25, 98765.4321] { + let c = Point::new(x, -0.00005); + let run = GradientGeom { start: c, end: c + Vec2::new(1e-6, 0.0), aspect: 1.0, focal: None }; + let off_center = GradientGeom { focal: Some(c + Vec2::new(0.5e-6, 0.0)), ..run }; + for (kind, geom) in [(GradientKind::Diamond, run), (GradientKind::Radial, run), (GradientKind::Radial, off_center)] { + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(kind, black_white()) }; + let p = g.padded_over(&geom, area).unwrap(); + assert!(p.capped, "{kind:?}"); + for q in [Point::new(x, 1e-5), Point::new(x + 2e-5, 2e-5), Point::new(x - 1e-5, 0.0001)] { + let level = geom.param_at(kind, q); + assert!((40.0..200.0).contains(&level), "{kind:?} {q:?}: level {level}"); + let want = g.sample(g.spread_param(level) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(kind, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{kind:?} {q:?}: {want:?} vs {got:?}"); + } + } + } +} + +/// Runs a billion levels from a radial's center are past what the stops' offsets can tell apart: +/// the gradient is written capped, its stops still apart and in order. +#[test] +fn runs_too_far_from_the_center_are_capped() { + let c = Point::new(0.0, 0.0); + let geom = GradientGeom { start: c, end: c + Vec2::new(1e-6, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(GradientKind::Radial, black_white()) }; + let p = g.padded_over(&geom, Rect::new(1000.0, 0.0, 1000.0001, 0.0001)).unwrap(); + assert!(p.capped); + assert!(p.gradient.stops.windows(2).all(|w| w[0].offset <= w[1].offset), "in order"); + let distinct = p.gradient.stops.windows(2).filter(|w| w[1].offset > w[0].offset).count(); + assert!(distinct * 2 + 2 >= p.gradient.stops.len(), "the runs stay apart: {distinct} of {}", p.gradient.stops.len()); +} + +/// A run of 1,000 close stops is written only as far out as their offsets stay apart: around +/// level 50,000 it is capped, every stop written apart from the next. +#[test] +fn close_stops_are_written_only_where_they_stay_apart() { + let many: Vec = (0..=1000).map(|i| GradientStop::new(i as f32 / 1000.0, Color::gray(i as f32 / 1000.0))).collect(); + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.001, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Repeat, repeats: 1, ..Gradient::new(GradientKind::Diamond, many) }; + let p = g.padded_over(&geom, Rect::new(50.0, 0.0, 50.002, 0.002)).unwrap(); + assert!(p.capped); + // Only where one run meets the next do two stops share an offset. + let runs = p.gradient.stops.len() / 1001; + let shared = p.gradient.stops.windows(2).filter(|w| w[1].offset <= w[0].offset).count(); + assert!(shared < runs, "{shared} stops share an offset in {runs} runs"); + // Stops 0.0001234 of a run apart are written out to level 123, on whole runs: an area within + // it gets the gradient's colors, Reflect's mirrored runs included. + let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(0.0001234, Color::gray(0.5)), GradientStop::new(1.0, Color::WHITE)]; + let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(GradientKind::Diamond, stops) }; + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0), aspect: 1.0, focal: None }; + let p = g.padded_over(&geom, Rect::new(121.0, 0.0, 123.9, 0.2)).unwrap(); + assert!(p.capped, "past level 123 it pads"); + for x in [121.3, 121.9, 122.5] { + let q = Point::new(x, 0.1); + let want = g.sample(g.spread_param(geom.param_at(GradientKind::Diamond, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(GradientKind::Diamond, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{x}: {want:?} vs {got:?}"); + } +} + +/// A piece of a gradient that starts where two runs meet (a gradient along a stroke, sliced at a +/// corner there) starts with the new run's first color, not the old run's last. +#[test] +fn a_piece_starting_at_a_sharp_step_takes_the_color_after_it() { + let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, Gradient::default().stops) }; + let piece = g.unrolled().span(0.5, 1.0); + assert_eq!(piece.stops.first().map(|s| s.color), Some(Color::WHITE), "{:?}", piece.stops); + assert_eq!(piece.stops.last().map(|s| s.color), Some(Color::BLACK)); + let before = g.unrolled().span(0.0, 0.5); + assert_eq!((before.stops[0].color, before.stops.last().unwrap().color), (Color::WHITE, Color::BLACK)); +} + +/// An area very far past a very short reflecting vector (a file's numbers) is padded without the +/// run count overflowing. +#[test] +fn a_far_area_pads_without_overflowing() { + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(1e-300, 0.0), aspect: 1.0, focal: None }; + let g = Gradient { spread: GradientSpread::Reflect, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, black_white()) }; + let p = g.padded_over(&geom, Rect::new(1e3, 0.0, 2e3, 1.0)); + assert!(p.is_none_or(|p| p.gradient.stops.len() <= MAX_SPREAD_STOPS)); + // Runs of 1e-8: the area's levels lie past 1e19, more than an integer holds. + let geom = GradientGeom { end: Point::new(1e-6, 0.0), ..geom }; + let p = g.padded_over(&geom, Rect::new(1e11, 0.0, 1e11 + 1.0, 1.0)).unwrap(); + assert!(p.capped && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{} {} {:?}", p.capped, p.gradient.stops.len(), p.geom); +} + +/// Unrolled, the stops run `repeats` times between 0 and 1 (every other run mirrored with +/// Reflect); one cycle is a run, or a run and its mirror. +#[test] +fn unrolled_and_cycle_stops() { + let g = Gradient { spread: GradientSpread::Reflect, repeats: 3, ..Gradient::new(GradientKind::Linear, black_white()) }; + let u = g.unrolled(); + let offsets: Vec = u.stops.iter().map(|s| (s.offset * 3.0).round()).collect(); + assert_eq!(offsets, [0.0, 1.0, 1.0, 2.0, 2.0, 3.0]); + let colors: Vec = u.stops.iter().map(|s| s.color).collect(); + assert_eq!(colors, [Color::BLACK, Color::WHITE, Color::WHITE, Color::BLACK, Color::BLACK, Color::WHITE]); + for t in [0.05, 0.4, 0.71, 0.99] { + let (a, b) = (g.sample(g.spread_param(t) as f32).0.to_rgb(), u.sample(t as f32).0.to_rgb()); + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4), "{t}"); + } + let (cycle, runs) = g.cycle().unwrap(); + assert_eq!((runs, cycle.stops.len()), (2, 4)); + let repeat = Gradient { spread: GradientSpread::Repeat, ..g.clone() }; + assert_eq!(repeat.cycle().unwrap().1, 1); + assert!(Gradient::default().cycle().is_none()); + assert!(matches!(Gradient::default().unrolled(), std::borrow::Cow::Borrowed(_))); +} + +/// Pixels (what the canvas draws a diamond, dithered or Perceptual gradient as, and what SVG and +/// PDF write as images) repeat with the stops, for each kind. +#[test] +fn pixels_repeat_and_reflect_for_every_kind() { + let b = Rect::new(0.0, 0.0, 40.0, 40.0); + let c = Point::new(20.0, 20.0); + for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond] { + for sp in [GradientSpread::Repeat, GradientSpread::Reflect] { + for method in [GradientMethod::Classic, GradientMethod::Perceptual] { + let mut g = GradientPaint::new(Gradient { spread: sp, repeats: 3, method, ..Gradient::new(kind, Gradient::default().stops) }); + let geom = if kind == GradientKind::Linear { + GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(40.0, 10.0), aspect: 1.0, focal: None } + } else { + GradientGeom { start: c, end: c + Vec2::new(18.0, 4.0), aspect: 0.8, focal: None } + }; + g.geom = Some(geom); + let px: Vec<[u8; 4]> = g.pixels(b, b, 40, 40, false, &Color::to_rgb).collect(); + for (i, got) in px.iter().enumerate().step_by(37) { + let p = Point::new((i % 40) as f64 + 0.5, (i / 40) as f64 + 0.5); + let t = g.gradient.spread_param(geom.param_at(kind, p)); + // Not where Repeat jumps from the last stop back to the first. + if sp == GradientSpread::Repeat && !(0.03..0.97).contains(&t) { + continue; + } + let want = g.gradient.sample(t as f32).0.to_rgba8(1.0); + assert!(got[0].abs_diff(want[0]) <= 2, "{kind:?} {sp:?} {method:?} {p:?}: {got:?} vs {want:?}"); + } + } + } + } +} + +/// A conic gradient's wedges: as many for each time the stops run around as for one turn, at most +/// so many. +#[test] +fn conic_wedges_count_each_run() { + let g = spread(GradientKind::Conic, GradientSpread::Repeat, 4); + assert_eq!(g.conic_wedge_count(360), 1440); + assert_eq!(spread(GradientKind::Conic, GradientSpread::Pad, 4).conic_wedge_count(360), 360); + assert_eq!(spread(GradientKind::Conic, GradientSpread::Reflect, MAX_REPEATS).conic_wedge_count(360), crate::gradient::MAX_CONIC_WEDGES); +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 03e7dc10e7..0d84e9e6b3 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -16,7 +16,7 @@ pub mod swatch; pub use blend::BlendMode; pub use freeform::{Freeform, FreeformMode, FreeformPoint}; -pub use gradient::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop}; +pub use gradient::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientSpread, GradientStop}; pub use libraries::SwatchLibrary; pub use swatch::{Swatch, SwatchGroup, default_swatches}; diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs index 800785f6f7..996c963c11 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -196,8 +196,10 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String { let at = if mirrored { 1.0 - s.offset } else { s.offset }; format!("{} {}%", css_color(&s.color, s.opacity), at * 100.0) }; + // Stops that repeat or reflect run that many times across the box. + let unrolled = g.gradient.unrolled(); let stops: Vec = - if mirrored { g.gradient.stops.iter().rev().map(stop).collect() } else { g.gradient.stops.iter().map(stop).collect() }; + if mirrored { unrolled.stops.iter().rev().map(stop).collect() } else { unrolled.stops.iter().map(stop).collect() }; match g.gradient.kind { GradientKind::Radial => format!("radial-gradient(circle, {})", stops.join(", ")), // CSS sweeps clockwise from straight up; the angle is counted counterclockwise diff --git a/crates/color/src/palette_io/tests.rs b/crates/color/src/palette_io/tests.rs index eaee50dd59..f3de69baf9 100644 --- a/crates/color/src/palette_io/tests.rs +++ b/crates/color/src/palette_io/tests.rs @@ -261,6 +261,29 @@ fn css_preserves_fractional_gradient_stop_positions() { } } +/// A gradient swatch that repeats or reflects has its stops written that many times across the +/// box, every other run backwards for Reflect. +#[test] +fn css_writes_spread_gradient_swatches_with_their_stops_repeated() { + for (spread, want) in [ + (crate::GradientSpread::Repeat, "#ffffff 0%, #000000 50%, #ffffff 50%, #000000 100%"), + (crate::GradientSpread::Reflect, "#ffffff 0%, #000000 50%, #000000 50%, #ffffff 100%"), + ] { + let lib = SwatchLibrary { + name: "x".into(), + swatches: vec![Swatch { + name: "Bands".into(), + paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { spread, repeats: 2, ..Gradient::default() }))), + global: false, + spot: false, + }], + groups: vec![], + }; + let text = written(&lib, PaletteFormat::Css); + assert!(text.contains(&format!("--bands: linear-gradient(90deg, {want});")), "{spread:?}: {text}"); + } +} + #[test] fn holds_counts_the_swatches_each_format_writes() { // `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group. diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 4e51777060..2f8b40014e 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -152,6 +152,11 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option (g.gradient.spread, g.gradient.repeat_count()), + p => panic!("a gradient, not {p:?}"), + }; + let (two, twenty) = (spread(GradientSpread::Repeat, 2), spread(GradientSpread::Repeat, 20)); + assert_eq!(at(&two, &twenty, 0.25), (GradientSpread::Repeat, 7)); + assert_eq!(at(&two, &twenty, 0.5), (GradientSpread::Repeat, 11)); + assert_eq!(at(&two, &twenty, 1.0), (GradientSpread::Repeat, 20)); + let pad = spread(GradientSpread::Pad, 1); + assert_eq!(at(&pad, &twenty, 0.25), (GradientSpread::Pad, 1)); + assert_eq!(at(&pad, &twenty, 0.75), (GradientSpread::Repeat, 20)); + } + #[test] fn blend_between_tints_of_one_swatch_keeps_the_link() { let tinted = |tint: f32| Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint), swatch: Some("Spot".into()), tint }; diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 4bddf80b0a..ec54be20cd 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -367,9 +367,12 @@ pub struct Slice { } impl Slice { - /// The slice's paint: its piece of `g` as a linear gradient placed from `from` to `to`. - pub fn paint(&self, g: &Gradient) -> Paint { - let gradient = g.span(self.span.0 as f32, self.span.1 as f32); + /// The slice's paint: its piece of `unrolled` as a linear gradient placed from `from` to `to`. + /// `unrolled` is the stroke's gradient as [`Gradient::unrolled`] gives it (unrolled once for + /// all the slices), so one that repeats or reflects runs its stops that many times along (or + /// across) the stroke. + pub fn paint(&self, unrolled: &Gradient) -> Paint { + let gradient = unrolled.span(self.span.0 as f32, self.span.1 as f32); let end = if self.from.distance(self.to) > 1e-9 { self.to } else { self.from + Vec2::new(1.0, 0.0) }; let geom = GradientGeom { start: self.from, end, aspect: 1.0, focal: None }; Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle: geom.angle_deg(), ..GradientPaint::new(gradient) })) @@ -471,35 +474,60 @@ pub fn written_slices(bp: &BezPath, rule: FillRule, st: &StrokeLayer, outlines: _ => vectorcraft_pathops::stroke_region(outlines).to_bezpath(), }; let grow = if g.stops.iter().all(|s| s.opacity >= 1.0) { SLICE_GROW } else { 0.0 }; + let unrolled = g.unrolled(); let slices = gradient_slices(bp, rule, st, SLICE_TOL, SLICE_MARGIN, grow) .into_iter() .map(|s| { - let paint = s.paint(g); + let paint = s.paint(&unrolled); (s.shape, paint) }) .collect(); Some(WrittenSlices { clip, slices }) } +/// How far before and after a sharp step between two colors (two stops at one offset, as a +/// gradient that repeats has where one run meets the next) [`mesh_marks`] puts a mesh row or +/// column, as a fraction of the gradient's length. +const STEP_GAP: f64 = 1e-4; + +/// Where along gradient `g` (0 to 1) Outline Stroke's mesh puts a row or column for its stops +/// ([`Gradient::expanded_stops`]): at each stop, and for two stops at one offset (a sharp step, +/// or where a reflecting gradient turns back) also [`STEP_GAP`] before and after it, so the mesh +/// takes the color on each side. +fn mesh_marks(g: &Gradient) -> Vec { + let offsets: Vec = g.expanded_stops().iter().map(|s| f64::from(s.0)).collect(); + let shared = |i: usize| { + let t = offsets.get(i).copied().unwrap_or(0.0); + let near = |j: Option| j.and_then(|j| offsets.get(j)).is_some_and(|&u| (u - t).abs() < 1e-6); + near(i.checked_sub(1)) || near(i.checked_add(1)) + }; + let mut out = Vec::with_capacity(offsets.len() + 2); + for (i, &t) in offsets.iter().enumerate() { + out.push(t); + if shared(i) { + out.extend([(t - STEP_GAP).max(0.0), (t + STEP_GAP).min(1.0)]); + } + } + out +} + /// Outline Stroke of stroke `st` of `bp` (filled with `rule`) whose gradient `g` runs along or /// across it: a gradient mesh per subpath over all the stroke can paint there (clip it to the /// stroke's outline), coloured as [`gradient_slices`] paint it. pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec { + let g = &*g.unrolled(); let across = st.gradient_mode == StrokeGradientMode::Across; let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); + let at_stops = mesh_marks(g); // Along: a column at each stop (colours are linear between columns), close enough to look smooth. - let marks: Vec = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() }; + let marks: Vec = if across { vec![] } else { at_stops.clone() }; let max_dt = match (across, st.profile.is_some()) { (false, _) => MESH_STEP, (true, true) => PROFILE_STEP, (true, false) => 1.0, }; // Rows across: the strip's edges, then (across) the band's left edge to its right edge with a row at each stop. - let mut us: Vec = if across { - (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(g.expanded_stops().iter().map(|s| s.0 as f64)).collect() - } else { - vec![0.5] - }; + let mut us: Vec = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(at_stops).collect() } else { vec![0.5] }; us.sort_by(f64::total_cmp); us.dedup_by(|a, b| (*a - *b).abs() < 1e-6); strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt) diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index ea966ea076..3745e4a6f6 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -286,6 +286,65 @@ fn a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone() { assert_covered(&hairpin, &st); } +/// A gradient along or across a stroke that repeats or reflects runs its stops that many times +/// along (or across) it: a slice's paint, and Outline Stroke's meshes, give each point the color +/// the spread gives its place. +#[test] +fn a_spread_gradient_runs_its_stops_along_the_stroke_that_many_times() { + use vectorcraft_color::GradientSpread; + let bp = poly(&[(0.0, 0.0), (120.0, 0.0)], false); + for (mode, spread) in [StrokeGradientMode::Along, StrokeGradientMode::Across] + .into_iter() + .flat_map(|m| [GradientSpread::Reflect, GradientSpread::Repeat].map(|s| (m, s))) + { + let mut st = stroke(10.0, mode); + let g = Gradient { spread, repeats: 3, ..Gradient::default() }; + st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + for s in slices(&bp, &st) { + let Paint::Gradient(p) = s.paint(&g.unrolled()) else { panic!("a gradient slice") }; + let geom = p.geom.unwrap(); + for u in [0.1, 0.5, 0.9] { + let at = s.from + (s.to - s.from) * u; + let t = s.span.0 + (s.span.1 - s.span.0) * u; + // Where two runs meet, Repeat's color is the next run's start or the last one's end. + if (t * 3.0 - (t * 3.0).round()).abs() < 1e-9 { + continue; + } + let want = g.sample(g.spread_param(t) as f32).0.to_rgb(); + let got = p.gradient.sample(geom.param_at(vectorcraft_color::GradientKind::Linear, at) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-3), "{mode:?} at {t}: {want:?} vs {got:?}"); + } + } + // Outline Stroke: the mesh comes back to white where each run starts again, and reaches + // black where each run ends, on both sides of a sharp step between runs. + let count = |g: &Gradient, hex: &str| { + gradient_meshes(&bp, FillRule::NonZero, &st, g).iter().flat_map(|m| m.points.iter()).filter(|p| p.color.to_hex() == hex).count() + }; + for hex in ["#ffffff", "#000000"] { + let (spread, pad) = (count(&g, hex), count(&Gradient::default(), hex)); + assert!(spread > pad, "{mode:?} {:?} {hex}: {spread} vs {pad}", g.spread); + } + } +} + +/// Where a reflecting gradient turns back past a narrow last stop, Outline Stroke's mesh keeps that +/// stop's color. +#[test] +fn a_narrow_peak_where_a_reflecting_gradient_turns_back_stays_in_the_mesh() { + use vectorcraft_color::GradientSpread; + let bp = poly(&[(0.0, 0.0), (120.0, 0.0)], false); + let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(0.995, Color::BLACK), GradientStop::new(1.0, Color::WHITE)]; + for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] { + let mut st = stroke(10.0, mode); + let g = Gradient { spread: GradientSpread::Reflect, repeats: 100, ..Gradient::new(vectorcraft_color::GradientKind::Linear, stops.clone()) }; + st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + let whites = + gradient_meshes(&bp, FillRule::NonZero, &st, &g).iter().flat_map(|m| m.points.iter()).filter(|p| p.color.to_hex() == "#ffffff").count(); + // The gradient turns back at white 50 times. + assert!(whites >= 50, "{mode:?}: {whites}"); + } +} + /// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along /// the path, however many points lie that far: not as far as it is wide anywhere when more than /// 256 do. diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 1d39e2b4e4..5737235963 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, expand ), @@ -476,11 +476,14 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?; vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))] } else { + // A gradient that repeats or reflects expands as its stops repeated over the box. + let (g, runs) = padded_on(g, bounds); + let g = &*g; let frame = Frame::new(g, bounds); match to { GradientTo::Mesh => vec![Node::new(d.alloc_id(), NodeKind::Mesh(frame.mesh(&g.gradient)))], GradientTo::Objects(n) => frame - .pieces(&g.gradient, n as usize) + .pieces(&g.gradient, n as usize, runs) .into_iter() .map(|(shape, (color, opacity))| { let fill = FillLayer { opacity, overprint: f.overprint, ..FillLayer::new(Paint::solid(color)) }; @@ -1234,8 +1237,64 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model (super::paint::stored_color(store_in, c), a) }); } + let (geom, spread) = (g.resolve(bounds), g.gradient.spreader()); + Box::new(move |p| g.gradient.sample_keep_model(spread(geom.param_at(g.gradient.kind, p)) as f32)) +} + +/// `g` placed on box `bounds` as a gradient that pads past its ends: a linear, radial or diamond +/// gradient that repeats or reflects with its stops repeated as many times as the box needs +/// ([`vectorcraft_color::Gradient::padded_over`]), a conic one with its stops repeated around the +/// turn ([`vectorcraft_color::Gradient::unrolled`]); others as they are. With it, for the repeated +/// stops, where along the vector (0 to 1) their first run starts and how many runs lie evenly from +/// there to 1. +fn padded_on(g: &GradientPaint, bounds: Rect) -> (std::borrow::Cow<'_, GradientPaint>, Option<(f64, usize)>) { + use std::borrow::Cow; + if !g.gradient.is_spread() { + return (Cow::Borrowed(g), None); + } let geom = g.resolve(bounds); - Box::new(move |p| g.gradient.sample_keep_model(geom.param_at(g.gradient.kind, p) as f32)) + match g.gradient.padded_over(&geom, bounds) { + Some(p) => { + let runs = Some((p.first, p.runs)); + (Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs) + } + None => { + let runs = Some((0.0, g.gradient.repeat_count() as usize)); + (Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs) + } + } +} + +/// The edges (gradient parameters, from `lo` to `hi`) of about `n` bands [`Frame::pieces`] cuts: +/// evenly spaced, or for stops repeated in `runs` ([`padded_on`]: where the first run starts and +/// how many runs lie evenly from there to 1) the same whole number of bands in each run between +/// `lo` and `hi` (at least one, about `n` in all), so every run starts on an edge and no band +/// holds two runs' colors. +fn band_edges(lo: f64, hi: f64, n: usize, runs: Option<(f64, usize)>) -> Vec { + let n = n.max(1); + let even = || { + let step = (hi - lo) / n as f64; + (0..=n).map(|k| lo + k as f64 * step).collect() + }; + let Some((first, count)) = runs.filter(|&(first, count)| count > 0 && (0.0..1.0).contains(&first)) else { return even() }; + let run = (1.0 - first) / count as f64; + let (from, to) = (lo.max(first), hi.min(1.0)); + let shown = (to - from) / run; + if !(shown > 0.0 && shown.is_finite()) { + return even(); + } + let step = run / (n as f64 / shown).round().max(1.0); + // Both whole numbers of steps from `first`, at most `n` plus the runs shown, plus one. + let (k0, k1) = (((from - first) / step).ceil(), ((to - first) / step).floor()); + let mut edges = vec![lo]; + for k in (k0 as usize)..(k1 as usize).saturating_add(1) { + let t = first + k as f64 * step; + if t > edges.last().copied().unwrap_or(lo) + 1e-12 && t < hi - 1e-12 { + edges.push(t); + } + } + edges.push(hi); + edges } /// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every @@ -1404,10 +1463,17 @@ impl Frame { /// end, for a radial one, nested ellipses (largest first), for a conic one, wedges around the /// center from each band's start to the seam, for a diamond one, nested squares (largest /// first). Each paints over the ones below except its band, so no seam shows between bands. - /// With translucent stops each shape is just its band, so nothing doubles up. - fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> { + /// With translucent stops each shape is just its band, so nothing doubles up. Stops repeated + /// in `runs` ([`padded_on`]) get the same whole number of bands in each run ([`band_edges`]). + fn pieces(&self, g: &Gradient, n: usize, runs: Option<(f64, usize)>) -> Vec<(PathData, (Color, f32))> { let opaque = g.stops.iter().all(|s| s.opacity >= 1.0); + let color = |e: &[f64], k: usize| { + let (a, b) = (e.get(k).copied().unwrap_or(0.0), e.get(k + 1).copied().unwrap_or(1.0)); + g.sample_keep_model(((a + b) / 2.0) as f32) + }; if let Some((geom, b, _)) = &self.conic { + // Evenly spaced around the turn, as the wedges are. + let n = band_edges(0.0, 1.0, n, runs).len().saturating_sub(1); return geom .conic_wedges(*b, n, opaque) .into_iter() @@ -1419,51 +1485,52 @@ impl Frame { return vec![(shapes::rectangle(*b), g.sample_keep_model(1.0))]; }; // Nested squares about the center, largest first, as a radial gradient's ellipses. - let step = reach / n as f64; + let edges = band_edges(0.0, *reach, n, runs); let square = |t: f64| -> SubPath { let pts: Vec = [0.0, 0.25, 0.5, 0.75].iter().map(|&u| *frame * (diamond_perimeter(u).to_vec2() * t).to_point()).collect(); SubPath::polyline(&pts, true) }; - return (0..n) + return (0..edges.len().saturating_sub(1)) .rev() .map(|k| { - let mut shape = PathData::single(square((k + 1) as f64 * step)); + let mut shape = PathData::single(square(edges.get(k + 1).copied().unwrap_or(*reach))); if !opaque && k > 0 { - let mut hole = square(k as f64 * step); + let mut hole = square(edges.get(k).copied().unwrap_or(0.0)); hole.reverse(); shape.subpaths.push(hole); } - (shape, g.sample_keep_model(((k as f64 + 0.5) * step) as f32)) + (shape, color(&edges, k)) }) .collect(); } - let step = (self.t1 - self.t0) / n as f64; - let color = |k: usize| g.sample_keep_model((self.t0 + (k as f64 + 0.5) * step) as f32); + let edges = band_edges(self.t0, self.t1, n, runs); + let bands = edges.len().saturating_sub(1); + let edge = |k: usize| edges.get(k).copied().unwrap_or(self.t1); if self.radial { let ellipse = |t: f64| { let c = self.focal + (self.start - self.focal) * t; let m = Affine::new([self.along.x * t, self.along.y * t, self.across.x * t, self.across.y * t, c.x, c.y]); shapes::ellipse(Rect::new(-1.0, -1.0, 1.0, 1.0)).transformed(m) }; - return (0..n) + return (0..bands) .rev() .map(|k| { - let mut shape = ellipse((k + 1) as f64 * step); + let mut shape = ellipse(edge(k + 1)); if !opaque && k > 0 { - let mut hole = ellipse(k as f64 * step).subpaths.remove(0); + let mut hole = ellipse(edge(k)).subpaths.remove(0); hole.reverse(); shape.subpaths.push(hole); } - (shape, color(k)) + (shape, color(&edges, k)) }) .collect(); } - (0..n) + (0..bands) .map(|k| { - let a = self.t0 + k as f64 * step; - let b = if opaque { self.t1 } else { a + step }; + let a = edge(k); + let b = if opaque { self.t1 } else { edge(k + 1) }; let pts = [self.at(a, self.s0), self.at(b, self.s0), self.at(b, self.s1), self.at(a, self.s1)]; - (PathData::single(SubPath::polyline(&pts, true)), color(k)) + (PathData::single(SubPath::polyline(&pts, true)), color(&edges, k)) }) .collect() } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 9003e325a9..090a7cb46c 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -1,7 +1,8 @@ //! The Gradient panel and the Gradient tool: in-place gradient edits and the gradient vector. use serde_json::{Value, json}; -use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint}; +use vectorcraft_color::gradient::MAX_REPEATS; +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientSpread, GradientStop, Paint}; use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::{Document, Node, NodeKind, StrokeGradientMode}; use vectorcraft_geom::{Affine, Point, Rect}; @@ -23,7 +24,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), @@ -129,6 +130,36 @@ fn method_param(p: &Value) -> Parsed> { } } +/// The `spread` param of `p`, if given: what the gradient paints past the ends of its stops. +fn spread_param(p: &Value) -> Parsed> { + match p.get("spread") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_str().and_then(GradientSpread::parse).map(Some).ok_or_else(|| "`spread` must be pad, repeat or reflect".into()), + } +} + +/// The `repeats` param of `p`, if given: how many times the stops run along the vector with +/// Repeat and Reflect, a whole number from 1 to [`MAX_REPEATS`]. +fn repeats_param(p: &Value) -> Parsed> { + match p.get("repeats") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .filter(|n| n.fract() == 0.0 && (1.0..=f64::from(MAX_REPEATS)).contains(n)) + .map(|n| Some(n as u32)) + .ok_or_else(|| format!("`repeats` must be a whole number from 1 to {MAX_REPEATS}")), + } +} + +/// An error when `p` gives a spread other than pad for a gradient of `kind` that has no ends to +/// spread past (freeform). +fn spread_for(kind: GradientKind, p: &Value) -> Parsed<()> { + match spread_param(p)? { + Some(s) if !s.is_pad() && kind == GradientKind::Freeform => Err("`spread` applies to linear, radial, conic and diamond gradients".into()), + _ => Ok(()), + } +} + /// `v` (params holding `stops`) with each stop that names a `swatch` resolved through `d` /// ([`swatch_solid`]): a global or spot colour or a tint swatch gives the stop its colour, link and /// tint, a process colour just its colour. A stop linked to a swatch that no longer exists keeps @@ -271,6 +302,9 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed { Some(v) => v.as_bool().ok_or("`dither` must be true or false")?, }; gp.gradient.method = method_param(g)?.unwrap_or_default(); + spread_for(kind, g)?; + gp.gradient.spread = spread_param(g)?.unwrap_or_default(); + gp.gradient.repeats = repeats_param(g)?.unwrap_or(1); gp.angle = f64_or(g, "angle", 0.0); gp.swatch = str_param(g, "swatch").map(str::to_string); let geom = GeomEdit::parse(g)?; @@ -422,6 +456,13 @@ fn apply_gradient_edit_in_model( if let Some(m) = method_param(p)? { gp.gradient.method = m; } + spread_for(gp.gradient.kind, p)?; + if let Some(sp) = spread_param(p)? { + gp.gradient.spread = sp; + } + if let Some(n) = repeats_param(p)? { + gp.gradient.repeats = n; + } // Angle and aspect edits keep the focal point in its place in the extent ellipse. let before = gp.geom; if let Some(a) = p.get("angle").and_then(Value::as_f64) { @@ -516,6 +557,18 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { } _ => std::borrow::Cow::Borrowed(p), }; + // A spread for selected gradients leaves a freeform default (which has no ends) as it is. + let spreads = matches!(spread_param(p), Ok(Some(sp)) if !sp.is_pad()); + let for_default = match for_default { + q if spreads && !ids.is_empty() && default_kind == Some(GradientKind::Freeform) => { + let mut q = q.into_owned(); + if let Some(o) = q.as_object_mut() { + o.remove("spread"); + } + std::borrow::Cow::Owned(q) + } + q => q, + }; let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(&for_default, &default_paint, &stored), None, &|_| true, model) .map_err(|e| bad(C, e))?; let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform); diff --git a/crates/engine/src/cmd/gradpresets.rs b/crates/engine/src/cmd/gradpresets.rs index d27809fa78..29de2210ae 100644 --- a/crates/engine/src/cmd/gradpresets.rs +++ b/crates/engine/src/cmd/gradpresets.rs @@ -4,7 +4,7 @@ //! The built-in presets are the gradient swatches of the built-in swatch libraries //! ([`vectorcraft_color::libraries`]: the app's own computed palettes), each listed under its //! library's name. Saved presets are kept with the preferences ([`crate::Prefs::gradient_presets`]), -//! so every document has them. A preset is the colour ramp (type, stops, method, dither), not a +//! so every document has them. A preset is the color ramp (type, stops, method, dither, spread), not a //! placement: applying one edits the gradient in place, as `paint.editGradient` does. use serde::{Deserialize, Serialize}; @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Gradient Presets", [], None, - "{} → {presets: [{name, library: the built-in library (theme) it comes from, null for a saved one, builtIn, gradient: {kind, stops, method?, dither?} (as paint.setFill's `gradient`)}]} the Gradient panel's presets: the built-in ones by library, then the saved ones; names are unique", + "{} → {presets: [{name, library: the built-in library (theme) it comes from, null for a saved one, builtIn, gradient: {kind, stops, method?, dither?, spread?, repeats?} (as paint.setFill's `gradient`)}]} the Gradient panel's presets: the built-in ones by library, then the saved ones; names are unique", always, list ), @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Apply Gradient Preset", [], None, - "{name: a preset's name (gradient.presets.list), stroke?, item?, ids?|id?, target? (as paint.editGradient)} give the gradient behind the active proxy (or the targeted fill or stroke) the preset's type, stops and method, and a fill its dither, keeping its placement; a solid or no paint becomes the gradient. One undo step, as paint.editGradient → {name, ids: the objects painted (none: only the default changed)}", + "{name: a preset's name (gradient.presets.list), stroke?, item?, ids?|id?, target? (as paint.editGradient)} give the gradient behind the active proxy (or the targeted fill or stroke) the preset's type, stops, method, spread and repeats (a freeform preset's: the first three), and a fill its dither, keeping its placement; a solid or no paint becomes the gradient. One undo step, as paint.editGradient → {name, ids: the objects painted (none: only the default changed)}", has_doc, apply ), @@ -42,7 +42,7 @@ pub fn specs() -> Vec { "Save Gradient Preset", [], None, - "{name?: (default: a new \"Gradient Preset N\"; unique, any case, and not a built-in preset's)} save the gradient behind the active proxy (paint.toggleActive picks Fill or Stroke) as a preset, kept with the preferences: its type, stops, method and dither (stops linked to a swatch keep their colour, unlinked) → {name}", + "{name?: (default: a new \"Gradient Preset N\"; unique, any case, and not a built-in preset's)} save the gradient behind the active proxy (paint.toggleActive picks Fill or Stroke) as a preset, kept with the preferences: its type, stops, method, dither, spread and repeats (stops linked to a swatch keep their colour, unlinked) → {name}", can_save, save ), @@ -90,12 +90,13 @@ fn builtin_presets() -> impl Iterator { }) } -/// Do `a` and `b` have the same colour ramp (type, method, dither and stops), swatch links aside? +/// Do `a` and `b` have the same color ramp (type, method, dither, spread as it paints and stops), +/// swatch links aside? fn same_ramp(a: &Gradient, b: &Gradient) -> bool { let stop = |x: &vectorcraft_color::GradientStop, y: &vectorcraft_color::GradientStop| { (x.offset, x.color, x.opacity, x.midpoint) == (y.offset, y.color, y.opacity, y.midpoint) }; - (a.kind, a.method, a.dither) == (b.kind, b.method, b.dither) + (a.kind, a.method, a.dither, a.painted_spread()) == (b.kind, b.method, b.dither, b.painted_spread()) && a.stops.len() == b.stops.len() && a.stops.iter().zip(&b.stops).all(|(x, y)| stop(x, y)) } @@ -215,6 +216,11 @@ fn apply(s: &mut Session, p: &Value) -> Result { o.insert("kind".into(), json!(g.kind.label().to_lowercase())); o.insert("stops".into(), stops_json(&g.stops)); o.insert("method".into(), json!(g.method.label().to_lowercase())); + // A freeform preset takes no spread (paint.editGradient refuses one). + if g.kind != vectorcraft_color::GradientKind::Freeform { + o.insert("spread".into(), json!(g.spread.label().to_lowercase())); + o.insert("repeats".into(), json!(g.repeat_count())); + } // Dither is a fill's (paint.editGradient refuses it on a stroke). if !stroke { o.insert("dither".into(), json!(g.dither)); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b199f9a356..c3b24463ae 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index c809d58090..751313957f 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -393,11 +393,12 @@ fn float_key(v: f32) -> Option { /// keys. type StopKey<'a> = (u32, ColorKey, u32, u32, Option<&'a str>, u32, Option<[u32; 4]>); -/// A gradient's kind, method, dither and stops, as keys. -type GradientKey<'a> = (u8, u8, bool, Vec>); +/// A gradient's kind, method, dither, spread and count as it paints them, and stops, as keys. +type GradientKey<'a> = (u8, u8, bool, (vectorcraft_color::GradientSpread, u32), Vec>); -/// A key that is equal for two gradients exactly when the gradients are equal (`==`); `None` when -/// any of its numbers is NaN, so the gradient equals no gradient. +/// A key that is equal for two gradients exactly when the gradients are equal (`==`), apart from +/// a count that a gradient which pads does not use ([`Gradient::painted_spread`]); `None` when any +/// of its numbers is NaN, so the gradient equals no gradient. fn gradient_key(g: &Gradient) -> Option> { let kind = match g.kind { GradientKind::Linear => 0, @@ -410,7 +411,7 @@ fn gradient_key(g: &Gradient) -> Option> { GradientMethod::Classic => 0, GradientMethod::Perceptual => 1, }; - Some((kind, method, g.dither, g.stops.iter().map(stop_key).collect::>()?)) + Some((kind, method, g.dither, g.painted_spread(), g.stops.iter().map(stop_key).collect::>()?)) } /// A stop's key for [`gradient_key`]; `None` when any of its numbers is NaN. diff --git a/crates/engine/src/tests/gradient.rs b/crates/engine/src/tests/gradient.rs index 73e0172da5..a2aea6341d 100644 --- a/crates/engine/src/tests/gradient.rs +++ b/crates/engine/src/tests/gradient.rs @@ -881,3 +881,134 @@ fn making_a_gradient_diamond_is_one_undo_step() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(fill_gradient(&s, id), before); } + +/// `paint.setFill` and `paint.editGradient` take a spread and a count for each kind of gradient +/// that has ends; the params give them back, the native format keeps them, and a change is one +/// undo step while an edit that changes nothing records none. +#[test] +fn a_gradient_takes_a_spread_and_a_count() { + use vectorcraft_color::GradientSpread; + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + for kind in ["linear", "radial", "conic", "diamond"] { + s.execute("paint.setFill", &json!({"gradient": {"kind": kind, "spread": "Reflect", "repeats": 4}})).unwrap(); + let g = fill_gradient(&s, id); + assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Reflect, 4), "{kind}"); + let params = vectorcraft_tools::params::gradient_params(&g); + assert_eq!((params["spread"].as_str(), params["repeats"].as_u64()), (Some("reflect"), Some(4)), "{kind}"); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.spread, GradientSpread::Pad, "a new gradient pads"); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient, g.gradient, "{kind}"); + } + let steps = s.doc().unwrap().history.undo.len(); + s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 2})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one step"); + let g = fill_gradient(&s, id); + assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Repeat, 2)); + s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 2})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "nothing changed: no step"); + s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.repeats, 2, "Pad keeps the count for later"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(fill_gradient(&s, id), g); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Paint::Gradient(saved) = back.node(id).unwrap().appearance.fill_paint() else { panic!("the gradient is lost") }; + assert_eq!((saved.gradient.spread, saved.gradient.repeats), (GradientSpread::Repeat, 2)); + // The params of a gradient that pads give back the count it keeps. + s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap(); + let params = vectorcraft_tools::params::gradient_params(&fill_gradient(&s, id)); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); + assert_eq!((fill_gradient(&s, id).gradient.spread, fill_gradient(&s, id).gradient.repeats), (GradientSpread::Pad, 2), "{params}"); +} + +/// A spread or count a gradient can't take is an error, from every command that sets one, and the +/// gradient stays as it was: an unknown spread, a count that is not a whole number from 1 to 100, +/// and a spread other than pad on a freeform gradient. +#[test] +fn a_bad_spread_or_count_is_refused() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "spread": "repeat", "repeats": 3}})).unwrap(); + let before = fill_gradient(&s, id); + let steps = s.doc().unwrap().history.undo.len(); + for bad in [json!({"spread": "mirror"}), json!({"spread": 1}), json!({"spread": true})] { + let e = s.execute("paint.editGradient", &bad).unwrap_err().to_string(); + assert!(e.contains("`spread` must be pad, repeat or reflect"), "{bad}: {e}"); + } + for n in [json!(0), json!(-2), json!(1.5), json!(101), json!(1e9), json!("3"), json!(f64::MAX)] { + let e = s.execute("paint.editGradient", &json!({ "repeats": n })).unwrap_err().to_string(); + assert!(e.contains("`repeats` must be a whole number from 1 to 100"), "{n}: {e}"); + let e = s.execute("paint.setFill", &json!({"gradient": {"kind": "linear", "repeats": n}})).unwrap_err().to_string(); + assert!(e.contains("`repeats`"), "{n}: {e}"); + } + let freeform = json!({"kind": "freeform", "spread": "reflect"}); + let e = s.execute("paint.editGradient", &freeform).unwrap_err().to_string(); + assert!(e.contains("`spread` applies to linear, radial, conic and diamond gradients"), "{e}"); + let e = s.execute("paint.setFill", &json!({ "gradient": freeform })).unwrap_err().to_string(); + assert!(e.contains("`spread` applies to"), "{e}"); + assert_eq!(fill_gradient(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // Pad on a freeform gradient is allowed (it has nothing to spread), and 100 is the most. + s.execute("paint.editGradient", &json!({"repeats": 100})).unwrap(); + s.execute("paint.editGradient", &json!({"kind": "freeform", "spread": "pad"})).unwrap(); + // A repeating gradient turned freeform: its params, which carry no spread, set it again. + s.execute("paint.editGradient", &json!({"kind": "radial", "spread": "reflect"})).unwrap(); + s.execute("paint.editGradient", &json!({"kind": "freeform"})).unwrap(); + let params = vectorcraft_tools::params::gradient_params(&fill_gradient(&s, id)); + assert!(params.get("spread").is_none(), "{params}"); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); +} + +/// A spread for a selected gradient applies while the gradient used last, the default, is a +/// freeform one, which keeps no spread. +#[test] +fn a_spread_for_a_selected_gradient_leaves_a_freeform_default_alone() { + use vectorcraft_color::GradientSpread; + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + rect(&mut s, 200.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "freeform"}})).unwrap(); + s.execute("paint.editGradient", &json!({"ids": [a.0], "spread": "repeat", "repeats": 3})).unwrap(); + let g = fill_gradient(&s, a); + assert_eq!((g.gradient.spread, g.gradient.repeats), (GradientSpread::Repeat, 3)); +} + +/// A preset keeps its spread and count: saved from a gradient, applied to another object. +#[test] +fn a_gradient_preset_keeps_its_spread() { + use vectorcraft_color::GradientSpread; + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "conic", "spread": "reflect", "repeats": 5}})).unwrap(); + s.execute("gradient.presets.save", &json!({"name": "Fan"})).unwrap(); + let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("gradient.presets.apply", &json!({"name": "Fan"})).unwrap(); + let g = fill_gradient(&s, b); + assert_eq!((g.gradient.kind, g.gradient.spread, g.gradient.repeats), (GradientKind::Conic, GradientSpread::Reflect, 5)); + assert_eq!(fill_gradient(&s, a).gradient.repeats, 5); +} + +/// A gradient set back to Pad keeps its count for later, and still matches a saved preset and a +/// swatch of the same gradient; while it repeats, the spread tells them apart. +#[test] +fn a_padding_gradient_matches_whatever_count_it_keeps() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let ramp = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + s.execute("paint.setFill", &json!({ "gradient": ramp })).unwrap(); + s.execute("gradient.presets.save", &json!({"name": "Plain"})).unwrap(); + s.execute("swatch.new", &json!({"name": "Plain", "paint": {"gradient": ramp}})).unwrap(); + let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].as_array().unwrap().contains(&json!("Plain")); + assert!(!unused(&mut s)); + s.execute("paint.editGradient", &json!({"spread": "repeat", "repeats": 5})).unwrap(); + assert_eq!(s.active_saved_gradient_preset(), None, "a repeating gradient is another ramp"); + assert!(unused(&mut s), "nothing paints the swatch's gradient"); + s.execute("paint.editGradient", &json!({"spread": "pad"})).unwrap(); + assert_eq!(fill_gradient(&s, a).gradient.repeats, 5); + assert_eq!(s.active_saved_gradient_preset(), Some("Plain")); + assert!(!unused(&mut s), "the gradient paints as the swatch does"); +} diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 84e5559851..3dacee533b 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -464,6 +464,50 @@ fn a_turned_or_flat_diamond_expands_as_the_canvas_draws_it() { } } +/// A gradient that repeats or reflects expands, into objects or a mesh, as the canvas paints it: +/// its stops repeated across the box, for each kind. +#[test] +fn a_spread_gradient_expands_as_the_canvas_paints_it() { + for kind in ["linear", "radial", "conic", "diamond"] { + for spread in ["repeat", "reflect"] { + for to in [json!({"steps": 240}), json!({"gradient": "mesh"})] { + let mut s = session(); + let gr = json!({"kind": kind, "spread": spread, "repeats": 3, "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gr, false); + let before = render(&s); + run(&mut s, "object.expand", to.clone()); + let after = render(&s); + // Where Repeat starts the stops again, the hard edge may land a pixel off. + assert!(mean_diff(&before, &after) < 4.0, "{kind} {spread} {to}: {}", mean_diff(&before, &after)); + } + } + } +} + +/// A gradient that repeats or reflects expands into the same whole number of objects in each run, +/// so each run starts on an object's edge: a sharp step in the middle of each of 7 runs, expanded +/// into about 15 objects (2 a run), lands where the canvas paints it. +#[test] +fn a_spread_gradient_expands_into_whole_bands_for_each_run() { + let stops = json!([ + {"offset": 0, "color": "#ffff00"}, {"offset": 0.5, "color": "#ffff00"}, + {"offset": 0.5, "color": "#004080"}, {"offset": 1, "color": "#004080"} + ]); + for kind in ["linear", "conic"] { + for spread in ["repeat", "reflect"] { + let mut s = session(); + // A conic gradient turns about the box's center. + let start = if kind == "conic" { [100, 100] } else { [30, 100] }; + let gr = json!({"kind": kind, "spread": spread, "repeats": 7, "start": start, "end": [170, 100], "stops": stops}); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gr, false); + let before = render(&s); + run(&mut s, "object.expand", json!({"steps": 15})); + let after = render(&s); + assert!(mean_diff(&before, &after) < 2.0, "{kind} {spread}: {}", mean_diff(&before, &after)); + } + } +} + #[test] fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { for gradient in [ diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 4939327c9d..edcd1e47b3 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -7658,3 +7658,144 @@ fn diamond_gradients_are_written_as_radial_gradients_through_their_corners() { let Some(Paint::Gradient(swatch)) = back.swatch("Gem").map(|s| &s.paint) else { panic!("the swatch is lost") }; assert_eq!(swatch.gradient.kind, GradientKind::Radial); } + +/// The data has no spread: a fill that repeats or reflects holds its stops repeated across the +/// shape and reads back looking as the canvas paints it; a swatch holds its stops repeated along +/// its vector; both with a warning. +#[test] +fn spread_gradients_are_written_with_their_stops_repeated() { + use vectorcraft_color::GradientSpread; + for kind in [GradientKind::Linear, GradientKind::Radial] { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Reflect, repeats: 2, ..Gradient::new(kind, stops) }); + let (start, end) = (vectorcraft_geom::Point::new(60.0, 50.0), vectorcraft_geom::Point::new(120.0, 50.0)); + g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect: 1.0, focal: None }); + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + let fill = Paint::Gradient(Box::new(g.clone())); + doc.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Appearance::basic(fill, Paint::None, 0.0))) + .unwrap(); + let mut swatches: Vec = doc.swatches.iter().cloned().collect(); + swatches.push(Swatch { + name: "Bands".into(), + paint: Paint::Gradient(Box::new(GradientPaint { geom: None, ..g })), + global: false, + spot: false, + }); + doc.swatches = swatches.into(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!( + out.warnings.iter().any(|w| w.contains("gradients that repeat or reflect are written with their stops repeated")), + "{:?}", + out.warnings + ); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.02 * st.pixels as f64, "{kind:?}: {} of {} pixels differ", st.bad_pixels, st.pixels); + let Some(Paint::Gradient(swatch)) = back.swatch("Bands").map(|s| &s.paint) else { panic!("the swatch is lost") }; + let colors: Vec = swatch.gradient.stops.iter().map(|s| s.color.to_hex()).collect(); + assert_eq!(colors, ["#ff0000", "#0000ff", "#0000ff", "#ff0000"], "{kind:?}"); + } +} + +/// A stroke's gradient that repeats is written with its stops repeated as far as the stroke's +/// miter joins reach, past the box the gradient is placed on, so a sharp join's tip shows the next +/// run rather than the color the gradient pads with; a straight line, which has no joins, keeps +/// every run within the stops the reader reads. +#[test] +fn a_stroke_spread_gradient_runs_on_into_its_miter_tips() { + use vectorcraft_color::GradientSpread; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 3, ..Gradient::new(GradientKind::Linear, stops) }); + // From the top of the box the gradient is placed on (the path's box grown by half the width). + let (start, end) = (vectorcraft_geom::Point::new(100.0, 15.0), vectorcraft_geom::Point::new(100.0, 85.0)); + g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect: 1.0, focal: None }); + let mut app = Appearance::basic(Paint::None, Paint::Gradient(Box::new(g)), 10.0); + for item in &mut app.items { + if let AppearanceItem::Stroke(s) = item { + (s.join, s.miter_limit) = (LineJoin::Miter, 10.0); + } + } + // A V whose apex's miter reaches about 10 pt above it, to y = 9.9. + let pts = [vectorcraft_geom::Point::new(60.0, 90.0), vectorcraft_geom::Point::new(100.0, 20.0), vectorcraft_geom::Point::new(140.0, 90.0)]; + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, Node::path(id, PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, false)]), app)).unwrap(); + let back = read_back(&doc); + let mut geom = None; + back.walk(|n| { + if let Paint::Gradient(g) = n.appearance.stroke_paint() { + geom = g.geom; + } + }); + let geom = geom.expect("the stroke keeps a gradient"); + assert!(geom.start.y.min(geom.end.y) < 9.9, "the runs start at {:?}", geom); + // A straight line has no joins: its 100 runs, 200 stops, fit the 256 the reader reads. + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(GradientKind::Linear, stops) }); + let (start, end) = (vectorcraft_geom::Point::new(50.0, 50.0), vectorcraft_geom::Point::new(150.0, 50.0)); + g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect: 1.0, focal: None }); + let line = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&[start, end], false)]); + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, Node::path(id, line, Appearance::basic(Paint::None, Paint::Gradient(Box::new(g)), 10.0))).unwrap(); + let mut stops = None; + read_back(&doc).walk(|n| { + if let Paint::Gradient(g) = n.appearance.stroke_paint() { + stops = Some(g.gradient.stops.len()); + } + }); + assert_eq!(stops, Some(200)); +} + +/// A conic fill (written linear, its stops repeated around its turn), a diamond fill (written +/// radial) and a swatch whose stops repeat more times than the 256 stops the reader reads stay +/// gradients: each holds its stops repeated as far as 256 stops go. +#[test] +fn spread_gradients_past_the_stops_the_reader_reads_stay_gradients() { + use vectorcraft_color::GradientSpread; + let three = || { + vec![ + GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), + GradientStop::new(0.5, Color::rgb(0.0, 1.0, 0.0)), + GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0)), + ] + }; + for kind in [GradientKind::Conic, GradientKind::Diamond] { + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(kind, three()) }); + let c = vectorcraft_geom::Point::new(100.0, 50.0); + g.geom = Some(vectorcraft_color::GradientGeom { start: c, end: c + vectorcraft_geom::Vec2::new(40.0, 0.0), aspect: 1.0, focal: None }); + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + let fill = Paint::Gradient(Box::new(g)); + doc.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Appearance::basic(fill, Paint::None, 0.0))) + .unwrap(); + let back = read_back(&doc); + let mut stops = None; + back.walk(|n| { + if let Paint::Gradient(g) = n.appearance.fill_paint() { + stops = Some(g.gradient.stops.len()); + } + }); + let stops = stops.unwrap_or_else(|| panic!("{kind:?}: the gradient is written as a solid color")); + assert!((200..=crate::import::MAX_GRADIENT_STOPS).contains(&stops), "{kind:?}: {stops} stops"); + } + // Stops at 0.1 and 0.9 run from 0 to 1 as four: 70 runs are 280 stops. + let stops = vec![GradientStop::new(0.1, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(0.9, Color::rgb(0.0, 0.0, 1.0))]; + let g = GradientPaint::new(Gradient { spread: GradientSpread::Reflect, repeats: 70, ..Gradient::new(GradientKind::Linear, stops) }); + let mut doc = Document::new(200.0, 100.0); + let mut swatches: Vec = doc.swatches.iter().cloned().collect(); + swatches.push(Swatch { name: "Bands".into(), paint: Paint::Gradient(Box::new(g)), global: false, spot: false }); + doc.swatches = swatches.into(); + let back = read_back(&doc); + let Some(Paint::Gradient(swatch)) = back.swatch("Bands").map(|s| &s.paint) else { panic!("the swatch is lost") }; + assert!((200..=crate::import::MAX_GRADIENT_STOPS).contains(&swatch.gradient.stops.len()), "{} stops", swatch.gradient.stops.len()); +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index dea4ad0c24..35a47cc437 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -896,7 +896,7 @@ impl<'a> Writer<'a> { self.warn_cut(Some(first)); return self.clip_contents(children); }; - let items = self.items(first, path.to_bezpath().bounding_box()); + let items = self.items(first, &path.to_bezpath()); let painted = self.plain_paint(&items); // The clipping path goes after the others, so its objects are counted first. let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); @@ -1004,7 +1004,7 @@ impl<'a> Writer<'a> { if path.is_empty() { return; } - let items = self.items(n, path.to_bezpath().bounding_box()); + let items = self.items(n, &path.to_bezpath()); if items.iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) { self.warn(ITEM_TRANSPARENCY); } diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index 0dbe24c937..f370167bcf 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -47,6 +47,7 @@ pub(super) struct PatternOut { pub(in crate::ai_editing) const DIAMOND: &str = "diamond gradients are written as radial gradients through their corners, with the same stops, in the editing data for other apps"; +pub(in crate::ai_editing) const SPREAD: &str = "gradients that repeat or reflect are written with their stops repeated across the shape (at most 256 stops, padding past them) in the editing data for other apps"; /// Can `g` be written as a gradient (a linear, radial, conic or diamond one with stops, no more /// than the `.ai` reader reads; a conic is written linear, [`CONIC`], and a diamond radial, @@ -85,11 +86,13 @@ impl Writer<'_> { pub(in crate::ai_editing) fn reserve_swatch_gradients(&mut self) { let doc = self.doc; for sw in doc.swatches_iter() { + // As `swatch_paint` writes it: the stops of one that repeats or reflects repeated. if let Paint::Gradient(g) = &sw.paint - && writable_gradient(&g.gradient) + && let gradient = g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS) + && writable_gradient(&gradient) && !self.gradients.iter().any(|d| d.name == sw.name) { - self.gradients.push(GradientDef { name: sw.name.clone(), gradient: g.gradient.clone(), swatch: true, used: false }); + self.gradients.push(GradientDef { name: sw.name.clone(), gradient: gradient.into_owned(), swatch: true, used: false }); } } } @@ -135,8 +138,27 @@ impl Writer<'_> { /// placed there (it then no longer depends on the shape it paints, which may be cut); one the /// data can't hold is its average color, with a warning. pub(super) fn placed(&mut self, p: &Paint, bounds: Rect) -> Paint { + self.placed_over(p, bounds, bounds) + } + + /// [`Self::placed`] for paint that reaches `area`: a gradient that repeats or reflects is + /// written with its stops repeated over `area`. + fn placed_over(&mut self, p: &Paint, bounds: Rect, area: Rect) -> Paint { let Paint::Gradient(g) = p else { return p.clone() }; - let geom = g.resolve(bounds); + let mut geom = g.resolve(bounds); + // The data has no spread: a gradient that repeats or reflects is written with its stops + // repeated over `area` (a conic one, written linear, around its turn). + let mut g: GradientPaint = (**g).clone(); + if g.gradient.is_spread() { + self.warn(SPREAD); + match g.gradient.padded_within(&geom, area, crate::import::MAX_GRADIENT_STOPS) { + Some(padded) => { + g.gradient = padded.gradient; + geom = padded.geom; + } + None => g.gradient = g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS).into_owned(), + } + } let finite = [geom.start.x, geom.start.y, geom.end.x, geom.end.y, geom.aspect].iter().all(|v| v.is_finite()); let radial = written_radial(g.gradient.kind); if g.gradient.kind == GradientKind::Freeform { @@ -147,13 +169,16 @@ impl Writer<'_> { self.warn(GRADIENT_SHAPES); return Paint::solid(average(&g.gradient)); } - Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..(**g).clone() })) + Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..g })) } - /// The fills and strokes of `n` that paint (outline `bounds`), their gradients placed - /// ([`Self::placed`]): a fill's on the shape's bounds, a stroke's on what it paints. A gradient - /// along or across a stroke is its average color (the stroke is written as its outline). - pub(super) fn items(&mut self, n: &vectorcraft_doc::Node, bounds: Rect) -> Vec { + /// The fills and strokes of `n` that paint (outline `bp`), their gradients placed + /// ([`Self::placed`]): a fill's on the shape's bounds, a stroke's on what it paints, the stops + /// of one that repeats or reflects repeated over the box the stroke paints, miter spikes + /// included ([`StrokeLayer::bounds_on`]). A gradient along or across a stroke is its average + /// color (the stroke is written as its outline). + pub(super) fn items(&mut self, n: &vectorcraft_doc::Node, bp: &BezPath) -> Vec { + let bounds = bp.bounding_box(); super::visible_items(n) .into_iter() .map(|i| match i { @@ -164,7 +189,11 @@ impl Writer<'_> { self.warn(GRADIENTS); Paint::solid(average(&g.gradient)) } - None => self.placed(&s.paint, s.paint_bounds(bounds)), + None => { + let spread = matches!(&s.paint, Paint::Gradient(g) if g.gradient.is_spread()); + let reach = if spread { s.bounds_on(bp, bounds) } else { bounds }; + self.placed_over(&s.paint, s.paint_bounds(bounds), reach) + } }; AppearanceItem::Stroke(StrokeLayer { paint, ..s.clone() }) } @@ -521,10 +550,19 @@ impl Writer<'_> { /// `(name) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p`. False for other paint, and for paint that can't /// be written. pub(super) fn swatch_paint(&mut self, s: &mut String, sw: &vectorcraft_color::swatch::Swatch) -> bool { + // A swatch has no placement: one that repeats or reflects holds its stops repeated along + // its vector. + let unrolled = match &sw.paint { + Paint::Gradient(g) => Some(g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS)), + _ => None, + }; match &sw.paint { - Paint::Gradient(g) if writable_gradient(&g.gradient) => { + Paint::Gradient(g) if unrolled.as_deref().is_some_and(writable_gradient) => { + if g.gradient.is_spread() { + self.warn(SPREAD); + } let swatch = Some(sw.name.as_str()); - let name = self.define_gradient(&g.gradient, swatch); + let name = self.define_gradient(unrolled.as_deref().unwrap_or(&g.gradient), swatch); s.push_str("Bb\n"); if written_radial(g.gradient.kind) { s.push_str("0 0 0 0 Bh\n"); diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 5e058acaf6..bf4798dd26 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -110,6 +110,8 @@ const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the const FREEFORM: &str = "freeform gradients are written as their average colour"; const CONIC: &str = "conic gradients are written as solid wedges clipped to the shape (legacy .ai files have no conic gradient)"; const DIAMOND: &str = "diamond gradients are written as four linear gradients clipped to the shape (legacy .ai files have no diamond gradient)"; +const SPREAD: &str = "gradients that repeat or reflect are written with their stops repeated across the shape (legacy .ai gradients only pad)"; +const SPREAD_CAPPED: &str = "gradients that repeat or reflect are written with at most 256 stops; they pad past those"; /// Wedges a conic gradient is written as: one a degree. const CONIC_WEDGES: usize = 360; const STROKES: &str = diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index 5528ad7529..3882e37fe1 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -41,8 +41,12 @@ pub(super) fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) s.push_str(" 3 "); } } - ps::push_nums(s, &[f64::from(stop.midpoint) * 100.0, f64::from(stop.offset) * 100.0]); - s.push_str("%_Bs\n"); + // The ramp point to five decimals of its percent: a gradient's stops repeated many times + // keep their places in each run. + ps::push_num(s, f64::from(stop.midpoint) * 100.0); + s.push(' '); + ps::push_num_places(s, f64::from(stop.offset) * 100.0, 5); + s.push_str(" %_Bs\n"); } s.push_str("BD\n%AI5_EndGradient\n"); } diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index b9ffcd0305..b8605c2680 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -110,6 +110,119 @@ fn conic_gradients_are_written_as_wedges_clipped_to_the_shape() { } } +/// The format's gradients pad past their ends: a linear gradient that repeats or reflects is +/// written with its stops repeated across the shape (a definition of its own, not named after the +/// swatch), and a conic one as wedges that repeat with the stops (which print as the canvas +/// draws them), with a warning. +#[test] +fn spread_gradients_are_written_with_their_stops_repeated() { + use vectorcraft_color::{GradientGeom, GradientSpread}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let linear = |spread| { + let mut g = GradientPaint::new(Gradient { spread, repeats: 3, ..Gradient::new(GradientKind::Linear, stops.clone()) }); + // Across the shape: three runs. + g.geom = Some(GradientGeom { + start: vectorcraft_geom::Point::new(20.0, 50.0), + end: vectorcraft_geom::Point::new(180.0, 50.0), + aspect: 1.0, + focal: None, + }); + g.swatch = Some("Bands".into()); + Paint::Gradient(Box::new(g)) + }; + for (spread, colors) in [ + (GradientSpread::Repeat, ["0 0 1", "1 0 0", "0 0 1", "1 0 0", "0 0 1", "1 0 0"]), + (GradientSpread::Reflect, ["0 0 1", "1 0 0", "1 0 0", "0 0 1", "0 0 1", "1 0 0"]), + ] { + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), linear(spread), Paint::None); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == SPREAD), "{:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes); + assert!(text.contains("(Gradient) 0 6 Bd") && !text.contains("(Bands)"), "{text}"); + // A linear definition lists its stops from the last to the first, each RGB stop as + // `c m y k r g b 2 …`. + let def = text.split("(Gradient) 0 6 Bd\n[\n").nth(1).unwrap(); + for (line, rgb) in def.lines().take(6).zip(colors) { + assert!(line.contains(&format!(" {rgb} 2 ")), "{spread:?}: {line} lacks {rgb}\n{def}"); + } + } + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Reflect, repeats: 3, ..Gradient::new(GradientKind::Conic, stops) }); + g.geom = Some(GradientGeom { + start: vectorcraft_geom::Point::new(100.0, 50.0), + end: vectorcraft_geom::Point::new(140.0, 30.0), + aspect: 1.0, + focal: None, + }); + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::ellipse(Rect::new(20.0, 10.0, 180.0, 90.0)), Paint::Gradient(Box::new(g)), Paint::None); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let text = String::from_utf8_lossy(&write(&doc).bytes).into_owned(); + assert!(text.matches("\nf\n").count() >= 3 * CONIC_WEDGES, "a wedge a degree for each run"); +} + +/// A gradient repeated more times than the reader keeps stops for is written with no more stops +/// than it reads, with a warning, so it opens with every stop that was written. +#[test] +fn many_repeats_are_written_within_the_stops_the_reader_reads() { + use vectorcraft_color::{GradientGeom, GradientSpread}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + // A run of 0.4 pt across a shape 160 pt wide: 400 runs. + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(GradientKind::Linear, stops) }); + g.geom = Some(GradientGeom { + start: vectorcraft_geom::Point::new(20.0, 50.0), + end: vectorcraft_geom::Point::new(60.0, 50.0), + aspect: 1.0, + focal: None, + }); + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Paint::Gradient(Box::new(g)), Paint::None); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == SPREAD_CAPPED), "{:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes).into_owned(); + let def = text.split("%AI5_BeginGradient: (Gradient)\n").nth(1).and_then(|d| d.split("%AI5_EndGradient").next()).unwrap(); + let written = def.matches(" %_Bs\n").count(); + assert!((2..=crate::import::MAX_GRADIENT_STOPS).contains(&written), "{written} stops"); + let read = crate::import(&out.bytes).unwrap().document; + let mut stops = None; + read.walk(|n| { + if let (None, Paint::Gradient(g)) = (&stops, n.appearance.fill_paint()) { + stops = Some(g.gradient.stops.len()); + } + }); + assert_eq!(stops, Some(written), "every stop written is read"); +} + +/// A radial gradient repeated out from a center far from the shape is written with its ramp points +/// (percent) to five decimals, so its runs keep their places hundreds of runs out. +#[test] +fn far_runs_keep_their_places() { + use vectorcraft_color::{GradientGeom, GradientSpread}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, ..Gradient::new(GradientKind::Radial, stops) }); + // Runs 0.25 pt apart, the shape 480 runs and more from the center. + let c = vectorcraft_geom::Point::new(-100.0, 50.0); + g.geom = Some(GradientGeom { start: c, end: c + vectorcraft_geom::Vec2::new(0.25, 0.0), aspect: 1.0, focal: None }); + let bounds = Rect::new(20.0, 10.0, 180.0, 90.0); + let padded = g.gradient.padded_within(&g.geom.unwrap(), bounds, crate::import::MAX_GRADIENT_STOPS).unwrap(); + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::rectangle(bounds), Paint::Gradient(Box::new(g)), Paint::None); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let text = String::from_utf8_lossy(&write(&doc).bytes).into_owned(); + let def = text.split("%AI5_BeginGradient: (Gradient)\n").nth(1).and_then(|d| d.split("%AI5_EndGradient").next()).unwrap(); + let ramps: Vec = def.lines().filter_map(|l| l.strip_suffix(" %_Bs")).map(|l| l.rsplit(' ').next().unwrap().parse().unwrap()).collect(); + assert_eq!(ramps.len(), padded.gradient.stops.len()); + for (ramp, stop) in ramps.iter().zip(&padded.gradient.stops) { + assert!((ramp / 100.0 - f64::from(stop.offset)).abs() < 2e-7, "{ramp} for {}", stop.offset); + } +} + /// The format has no diamond gradient: a diamond fill, and a diamond stroke (written as its filled /// outline), are four path objects clipped to the shape, each a linear gradient over a quadrant /// of the diamond's frame, with a warning; the definition is not named after the swatch. @@ -139,6 +252,32 @@ fn diamond_gradients_are_written_as_four_linear_gradients_clipped_to_the_shape() } } +/// A diamond gradient that repeats is four linear gradients over the quadrants of its frame, each +/// with its stops repeated out to the shape's edge, with a warning. +#[test] +fn a_spread_diamond_repeats_in_each_quadrant() { + use vectorcraft_color::GradientSpread; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 3, ..Gradient::new(GradientKind::Diamond, stops) }); + g.geom = Some(vectorcraft_color::GradientGeom { + start: vectorcraft_geom::Point::new(100.0, 50.0), + end: vectorcraft_geom::Point::new(140.0, 30.0), + aspect: 0.7, + focal: None, + }); + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::ellipse(Rect::new(20.0, 10.0, 180.0, 90.0)), Paint::Gradient(Box::new(g)), Paint::None); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == DIAMOND) && out.warnings.iter().any(|w| w == SPREAD), "{:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes).into_owned(); + assert_eq!(text.matches(" Bg\n").count(), 4, "a gradient a quadrant"); + let defs: Vec = + text.split("%AI5_BeginGradient: ").skip(1).map(|d| d.split("%AI5_EndGradient").next().unwrap_or("").matches(" %_Bs\n").count()).collect(); + assert!(!defs.is_empty() && defs.iter().all(|&n| n >= 6), "stops in each definition: {defs:?}"); +} + /// `doc` written with options `o`. Every file the tests write is also run as a PostScript program /// ([`prints_as`]) and must draw what `doc` draws. fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput { diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 3373330892..548a8dfda8 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -9,7 +9,7 @@ use super::{ ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, }; -use super::{CONIC, CONIC_WEDGES, DIAMOND}; +use super::{CONIC, CONIC_WEDGES, DIAMOND, SPREAD, SPREAD_CAPPED}; pub(super) struct Writer<'a> { doc: &'a Document, @@ -481,7 +481,8 @@ impl<'a> Writer<'a> { self.warn(TRANSPARENCY); } let area = path.to_bezpath().bounding_box(); - let wedges = g.resolve(area).conic_wedges(area, CONIC_WEDGES, true); + let wedges = g.resolve(area).conic_wedges(area, g.gradient.conic_wedge_count(CONIC_WEDGES), true); + let spread = g.gradient.spreader(); self.line("q"); self.fill_rule(rule); let several = path.subpaths.len() > 1; @@ -495,7 +496,7 @@ impl<'a> Writer<'a> { self.line("*U"); } for (pts, t) in wedges { - let (color, _) = g.gradient.sample_keep_model(t as f32); + let (color, _) = g.gradient.sample_keep_model(spread(t) as f32); let wedge = FillLayer { paint: Paint::solid(color), ..f.clone() }; self.path_object(&PathData::single(SubPath::polyline(&pts, true)), FillRule::NonZero, Some(&wedge), None); } @@ -672,6 +673,22 @@ impl<'a> Writer<'a> { /// (its distance a fraction of the radius, at most 1, §3.5.5; `0 0 0 0 Bh` at the center, /// §3.5.6), `1 (name) x y angle length 1 0 0 1 0 0 Bg`. fn gradient(&mut self, g: &GradientPaint, bounds: Rect) -> String { + // A gradient that repeats or reflects (the format's gradients pad past their ends): its + // stops repeated as many times as `bounds` needs, in a definition of its own, no more + // stops than the reader reads. + if let Some(padded) = g.gradient.padded_within(&g.resolve(bounds), bounds, crate::import::MAX_GRADIENT_STOPS) { + self.warn(if padded.capped { SPREAD_CAPPED } else { SPREAD }); + let geom = padded.geom; + let p = GradientPaint { gradient: padded.gradient, geom: Some(geom), angle: geom.angle_deg(), swatch: None, ..g.clone() }; + return self.gradient(&p, bounds); + } + // One whose levels over `bounds` are not finite (a vector of no length): its stops + // repeated along the vector, padding past it. + if g.gradient.is_spread() { + self.warn(SPREAD_CAPPED); + let p = GradientPaint { gradient: g.gradient.unrolled_within(crate::import::MAX_GRADIENT_STOPS).into_owned(), swatch: None, ..g.clone() }; + return self.gradient(&p, bounds); + } let name = self.define(&g.gradient, g.swatch.as_deref()); for stop in &g.gradient.stops { if stop.opacity < 1.0 { diff --git a/crates/eps/src/ps.rs b/crates/eps/src/ps.rs index 3f4b1049f5..8fef6d75dd 100644 --- a/crates/eps/src/ps.rs +++ b/crates/eps/src/ps.rs @@ -33,11 +33,19 @@ pub(crate) fn num(v: f64) -> String { /// Append [`num`]`(v)` to `s`. pub(crate) fn push_num(s: &mut String, v: f64) { - // Beyond ~1.8e304 the scaling overflows; such values have no fractional digits to round. + push_num_places(s, v, 4); +} + +/// Append `v` to `s` with at most `places` decimals (up to 9), no trailing zeros (0 when not +/// finite). +pub(crate) fn push_num_places(s: &mut String, v: f64, places: u32) { + let places = places.min(9); + let scale = 10f64.powi(places as i32); + // Past where the scaling overflows, values have no fractional digits to round. let v = if !v.is_finite() { 0.0 - } else if (v * 10_000.0).is_finite() { - (v * 10_000.0).round() / 10_000.0 + } else if (v * scale).is_finite() { + (v * scale).round() / scale } else { v }; @@ -46,7 +54,7 @@ pub(crate) fn push_num(s: &mut String, v: f64) { return; } let start = s.len(); - let _ = write!(s, "{v:.4}"); + let _ = write!(s, "{v:.*}", places as usize); let keep = s[start..].trim_end_matches('0').trim_end_matches('.').len(); s.truncate(start + keep); } @@ -291,7 +299,7 @@ pub(crate) fn inflate(data: &[u8], max: u64) -> Option> { #[cfg(test)] mod tests { - use super::{ascii85, ascii85_decode, num}; + use super::{ascii85, ascii85_decode, num, push_num_places}; #[test] fn ascii85_first_line_never_starts_with_percent() { @@ -308,4 +316,18 @@ mod tests { assert!(!s.is_empty() && s.chars().all(|c| c.is_ascii_digit() || c == '-' || c == '.'), "{v}: got {s:?}"); } } + + #[test] + fn numbers_keep_the_decimals_asked_for() { + let places = |v: f64, n: u32| { + let mut s = String::new(); + push_num_places(&mut s, v, n); + s + }; + assert_eq!(num(0.123_456_78), "0.1235"); + assert_eq!(places(0.123_456_78, 7), "0.1234568"); + assert_eq!(places(f64::from(0.4f32), 7), "0.4"); + assert_eq!(places(2.5, 7), "2.5"); + assert_eq!(places(f64::NAN, 7), "0"); + } } diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 96205aa7d3..5cc90117d7 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -44,8 +44,12 @@ const SPOT_GRADIENTS_SHARED: &str = "gradients whose spot inks can't share a Dev const SPOT_GRADIENTS_PERCEPTUAL: &str = "Perceptual gradients of spot colors are written in process colors"; const CONIC_WEDGES: &str = "conic gradients are written as solid wedges (EPS has no conic shading)"; const DIAMOND_QUADRANTS: &str = "diamond gradients are written as four linear gradients (EPS has no diamond shading)"; +const SPREAD_CAPPED: &str = + "gradients that repeat or reflect are written with at most 256 runs of their stops (at most 16384 stops); they pad past those"; /// Wedges a conic gradient is written as: one a degree. const CONIC_WEDGE_COUNT: usize = 360; +/// Decimals of a smooth shading's stitching function bounds (stop offsets, 0 to 1). +const BOUNDS_PLACES: u32 = 7; /// The most colorants a DeviceN color space may have (PostScript's limit). const MAX_DEVICE_N: usize = 32; const MISSING_PATTERN: &str = "missing patterns are written as mid-grey"; @@ -940,6 +944,23 @@ impl<'a> Scene<'a> { /// both levels; a diamond: four linear gradients, [`Self::diamond_quadrants`]). fn gradient(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) { let geom = g.resolve(bounds); + // A linear, radial or diamond gradient that repeats or reflects: PostScript's shadings + // pad past their ends, so its stops are repeated as many times as the area needs. + if let Some(padded) = g.gradient.padded_over(&geom, area) { + if padded.capped { + self.warn(SPREAD_CAPPED); + } + let geom = padded.geom; + let paint = GradientPaint { gradient: padded.gradient, geom: Some(geom), angle: geom.angle_deg(), ..g.clone() }; + return self.gradient(&paint, bounds, area); + } + // One whose levels over the area are not finite (a vector of no length): its stops + // repeated along the vector, padding past it. + if g.gradient.is_spread() && g.gradient.kind != GradientKind::Conic { + self.warn(SPREAD_CAPPED); + let paint = GradientPaint { gradient: g.gradient.unrolled().into_owned(), ..g.clone() }; + return self.gradient(&paint, bounds, area); + } let (space, stops) = self.gradient_stops(&g.gradient); if !stops.windows(2).any(|w| w[1].0 > w[0].0) { if let Some((_, c)) = stops.first() { @@ -951,7 +972,7 @@ impl<'a> Scene<'a> { let space = &space; let smooth = self.o.level == Level::Three && !self.o.compatible_gradients; match g.gradient.kind { - GradientKind::Conic => self.conic_steps(&geom, area, space, &stops), + GradientKind::Conic => self.conic_steps(&geom, area, space, &stops, &g.gradient), GradientKind::Diamond => self.diamond_quadrants(&geom, area, space, &stops, smooth), GradientKind::Radial => { let r = geom.length().max(1e-6); @@ -984,15 +1005,24 @@ impl<'a> Scene<'a> { /// A conic gradient as [`CONIC_WEDGE_COUNT`] solid wedges about its center covering `area` /// (PostScript has no conic shading), each running on to the seam under the next, so no /// hairline shows between neighbors ([`vectorcraft_color::GradientGeom::conic_wedges`]). - fn conic_steps(&mut self, geom: &vectorcraft_color::GradientGeom, area: Rect, space: &GradientSpace, stops: &[(f64, Vec)]) { + /// Stops of `g` that repeat or reflect get that many wedges for each time they run around. + fn conic_steps( + &mut self, + geom: &vectorcraft_color::GradientGeom, + area: Rect, + space: &GradientSpace, + stops: &[(f64, Vec)], + g: &vectorcraft_color::Gradient, + ) { self.warn(CONIC_WEDGES); + let spread = g.spreader(); let at = |t: f64| match stops.windows(2).find(|w| t <= w[1].0) { Some(w) if w[1].0 > w[0].0 => mix(&w[0].1, &w[1].1, ((t - w[0].0) / (w[1].0 - w[0].0)).clamp(0.0, 1.0) as f32), Some(w) => w[1].1.clone(), None => stops.last().map_or_else(Vec::new, |s| s.1.clone()), }; - for (pts, t) in geom.conic_wedges(area, CONIC_WEDGE_COUNT, true) { - self.set_comps(space, &at(t)); + for (pts, t) in geom.conic_wedges(area, g.conic_wedge_count(CONIC_WEDGE_COUNT), true) { + self.set_comps(space, &at(spread(t))); for (i, p) in pts.iter().enumerate() { push_nums(&mut self.body, &[p.x, p.y]); self.line(if i == 0 { "newpath moveto" } else { "lineto" }); @@ -1059,8 +1089,12 @@ impl<'a> Scene<'a> { f.push('\n'); } f.push_str("] /Bounds ["); - let bounds: Vec = many.iter().skip(1).map(|w| w[0].0).collect(); - push_nums(&mut f, &bounds); + // Seven decimals: a gradient's stops repeated hundreds or thousands of times + // (`Gradient::padded_over`) keep their places in each run. + for w in many.iter().skip(1) { + ps::push_num_places(&mut f, w[0].0, BOUNDS_PLACES); + f.push(' '); + } f.push_str("] /Encode ["); f.push_str(&"0 1 ".repeat(many.len())); f.push_str("] >>"); diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index db9ae2837c..b503421eb7 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -244,6 +244,82 @@ fn diamond_gradients_are_four_linear_gradients_clipped_to_quadrants() { ); } +/// PostScript's shadings pad past their ends: a linear, radial or diamond gradient that repeats or +/// reflects is written with its stops repeated over the shape, a conic one as wedges that repeat +/// with the stops; read back from the PostScript, each looks as the canvas paints it, at Level 3 +/// and Level 2. +#[test] +fn spread_gradients_are_written_with_their_stops_repeated() { + use vectorcraft_color::{GradientGeom, GradientSpread}; + let c = Point::new(100.0, 100.0); + let around = GradientGeom { start: c, end: c + vectorcraft_geom::Vec2::new(60.0, -25.0), aspect: 0.7, focal: None }; + let cases = [ + (GradientKind::Linear, GradientGeom { start: Point::new(70.0, 80.0), end: Point::new(150.0, 110.0), aspect: 1.0, focal: None }), + (GradientKind::Radial, around), + (GradientKind::Radial, GradientGeom { focal: Some(c + vectorcraft_geom::Vec2::new(-15.0, 12.0)), ..around }), + (GradientKind::Conic, around), + (GradientKind::Diamond, around), + ]; + for (kind, geom) in cases { + for spread in [GradientSpread::Repeat, GradientSpread::Reflect] { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient { spread, repeats: 3, ..Gradient::new(kind, stops) }); + g.geom = Some(geom); + let paint = Paint::Gradient(Box::new(g)); + let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), paint, Paint::None, 0.0)]); + for o in [opts(&d), EpsOptions { level: Level::Two, ..opts(&d) }] { + let out = export(&d, &o, None, None).unwrap(); + let back = import_with(&out.bytes, false).unwrap().document; + let (a, b) = (vectorcraft_testkit::raster::render_artboard(&d), vectorcraft_testkit::raster::render_artboard(&back)); + let st = vectorcraft_testkit::raster::perceptual_diff(&a, &b, 12.0); + assert!( + (st.bad_pixels as f64) < 0.03 * st.pixels as f64, + "{kind:?} {spread:?} level {:?}: {} of {} pixels differ", + o.level, + st.bad_pixels, + st.pixels + ); + } + } + } +} + +/// A gradient that repeats with no stops (a file can hold one) is written as one that pads, to +/// EPS, legacy `.ai` and the `.ai` editing data. +#[test] +fn a_spread_gradient_without_stops_is_written() { + use vectorcraft_color::GradientSpread; + for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond] { + let g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 3, ..Gradient::new(kind, vec![]) }); + let paint = Paint::Gradient(Box::new(g)); + let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), paint.clone(), paint, 4.0)]); + export(&d, &opts(&d), None, None).unwrap(); + export_legacy_ai(&d, &LegacyAiOptions::default()).unwrap(); + crate::export_ai_editing(&d, &crate::AiEditingOptions::default()).unwrap(); + } +} + +/// A gradient repeated 256 times across the shape keeps each run's place: the smooth shading's +/// stitching bounds lie where the runs meet, to well under a run's thousandth. +#[test] +fn many_repeats_keep_their_places_in_a_smooth_shading() { + use vectorcraft_color::{GradientGeom, GradientSpread}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + // A run of 0.4 pt across a shape 160 pt wide: capped at 256 runs. + let mut g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(GradientKind::Linear, stops) }); + g.geom = Some(GradientGeom { start: Point::new(20.0, 100.0), end: Point::new(60.0, 100.0), aspect: 1.0, focal: None }); + let paint = Paint::Gradient(Box::new(g)); + let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), paint, Paint::None, 0.0)]); + let page = page_of(&d, &opts(&d)); + let bounds: Vec = + page.split("/Bounds [").nth(1).and_then(|s| s.split(']').next()).unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + let runs = (bounds.len() + 1) as f64; + assert_eq!(runs, 256.0, "{bounds:?}"); + for b in bounds { + assert!((b * runs - (b * runs).round()).abs() < 1e-3, "bound {b} is {} runs along", b * runs); + } +} + #[test] fn strokes_keep_their_caps_joins_and_dashes() { let d = doc_with(|d| { diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 343b422093..ac5cf183cd 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -415,13 +415,13 @@ impl<'a> Scene<'a> { field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect() } else { let geom = g.resolve(bounds); - let sample = g.gradient.sampler(); + let (sample, spread) = (g.gradient.sampler(), g.gradient.spreader()); let (cw, ch) = (area.width() / f64::from(cols), area.height() / f64::from(rows)); (0..usize::from(rows) * usize::from(cols)) .flat_map(|i| { let (x, y) = (i % usize::from(cols), i / usize::from(cols)); let p = Point::new(area.x0 + (x as f64 + 0.5) * cw, area.y0 + (y as f64 + 0.5) * ch); - let t = geom.param_at(g.gradient.kind, p); + let t = spread(geom.param_at(g.gradient.kind, p)); let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; let (c, a) = sample(t as f32); c.to_rgba8(a) diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 90b7ae5248..2d4113a1fa 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -434,8 +434,9 @@ impl Exporter<'_> { /// [`Self::area`] draws as art: a pattern swatch or a gradient drawn as pixels /// ([`vectorcraft_color::GradientPaint::draws_as_pixels`]), but not a Perceptual one of spot /// inks ([`Self::perceptual_spot`]), a conic gradient that can't be a sweep shading (in - /// PDF/A, or with more than [`super::paint::MAX_SWEEP_STOPS`] stops), or a diamond gradient - /// (four linear gradients, [`Self::diamond`])? + /// PDF/A, or with more than [`super::paint::MAX_SWEEP_STOPS`] stops), a diamond gradient + /// (four linear gradients, [`Self::diamond`]), or a linear or radial gradient that repeats or + /// reflects (its stops repeated over the area, [`Self::padded`])? pub(super) fn area_paint(&self, p: &Paint, fill: bool) -> bool { match p { Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(), @@ -444,6 +445,7 @@ impl Exporter<'_> { (g.draws_as_pixels(fill) && !self.perceptual_spot(g, fill)) || (g.gradient.kind == GradientKind::Conic && sweep_unwritable()) || g.gradient.kind == GradientKind::Diamond + || (g.gradient.is_spread() && matches!(g.gradient.kind, GradientKind::Linear | GradientKind::Radial)) } _ => false, } @@ -452,7 +454,8 @@ impl Exporter<'_> { /// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity, /// blend mode) when it is an [area paint](Self::area_paint) of a fill (`fill`) or a stroke: a /// pattern's [tiling pattern](Self::tiling) (else its tile instances covering it), a diamond - /// gradient's four linear gradients, or an image of a gradient drawn as pixels, placed on + /// gradient's four linear gradients, a repeating or reflecting linear or radial gradient's + /// stops repeated over `area`, or an image of a gradient drawn as pixels, placed on /// `bounds` (the painted object's box, as on the canvas), clipped to it. False (nothing drawn) /// for other paints. #[allow(clippy::too_many_arguments)] @@ -504,7 +507,9 @@ impl Exporter<'_> { } Paint::Gradient(g) => { let pixels = g.draws_as_pixels(fill) && !self.perceptual_spot(g, fill); - if pixels || g.gradient.kind != GradientKind::Diamond || !self.diamond(s, g, bounds, area) { + // A linear or radial gradient that repeats or reflects is drawn by `padded`. + let drawn = !pixels && self.padded(s, g, bounds, area); + if !drawn && (pixels || g.gradient.kind != GradientKind::Diamond || !self.diamond(s, g, bounds, area)) { self.field_image(s, g, bounds, area, fill); } } diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 38ab52f182..acb96f1713 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -236,6 +236,24 @@ impl Exporter<'_> { Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()), Paint::Gradient(g) => { let geom = g.resolve(bounds); + // A linear or radial gradient that repeats or reflects, where it isn't an area + // paint ([`Self::padded`]): its stops repeated over `bounds`, padding past them. + if matches!(g.gradient.kind, GradientKind::Linear | GradientKind::Radial) && g.gradient.is_spread() { + let paint = match g.gradient.padded_over(&geom, bounds) { + Some(padded) => { + self.warn_capped(padded.capped); + padded_paint(g, padded) + } + // Levels over `bounds` that are not finite (a vector of no length): the + // stops repeated along the vector, padding past it. + None => { + self.warn_capped(true); + let gradient = g.gradient.unrolled().into_owned(); + Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { gradient, ..(**g).clone() })) + } + }; + return self.paint_in(s, &paint, bounds, space); + } let spot = match g.gradient.kind { GradientKind::Freeform => None, _ => self.perceptual_spot_stops(&g.gradient).or_else(|| self.spot_stops(&g.gradient)), @@ -318,20 +336,27 @@ impl Exporter<'_> { } // A function-based shading (ISO 32000-1, 8.7.4.5.2) whose calculator function // turns the angle about the origin into the ramp, once around (0 to 360 - // degrees), carried onto the extent ellipse by its frame. + // degrees), carried onto the extent ellipse by its frame. Stops that repeat + // or reflect `n` times run over 360/`n` degrees, the function repeating or + // reflecting them past it. GradientKind::Conic => { let frame = geom.conic_frame(); if !writable(s, space * frame, Rect::new(-1.0, -1.0, 1.0, 1.0)) { return None; } + let (end_angle, spread_method) = match g.gradient.spread { + _ if !g.gradient.is_spread() => (360.0, SpreadMethod::Pad), + vectorcraft_color::GradientSpread::Reflect => (360.0 / g.gradient.repeat_count() as f32, SpreadMethod::Reflect), + _ => (360.0 / g.gradient.repeat_count() as f32, SpreadMethod::Repeat), + }; Some( SweepGradient { cx: 0.0, cy: 0.0, start_angle: 0.0, - end_angle: 360.0, + end_angle, transform: xf(space * frame), - spread_method: SpreadMethod::Pad, + spread_method, stops, anti_alias: false, } @@ -484,15 +509,27 @@ impl Exporter<'_> { /// linear gradients, one for each quadrant of its frame, each equal to the diamond there (PDF /// has no diamond shading; [`vectorcraft_color::GradientGeom::diamond_quadrants`]). The /// quadrants overlap so no hairline shows between them, which needs opaque stops. False - /// (nothing drawn) for a diamond with translucent stops or without a frame, which + /// (nothing drawn) for a diamond with translucent stops or without a frame, for one with a + /// quadrant whose shading or outline PDF can't hold, and for a Perceptual one of spot inks + /// whose ink curve has too many tints once its stops are repeated, which /// [`Self::field_image`] draws instead. pub(super) fn diamond(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect) -> bool { if g.gradient.stops.iter().any(|st| st.opacity < 1.0) { return false; } - let Some(quadrants) = g.resolve(bounds).diamond_quadrants(area.abs()) else { return false }; - self.warn("diamond gradients are exported as four linear gradients"); - let gradient = vectorcraft_color::Gradient { kind: GradientKind::Linear, ..g.gradient.clone() }; + // A diamond that repeats or reflects: its stops repeated over the levels the area reaches + // from the center, then split into quadrants, each of which runs those levels. + let (diamond, geom, capped) = match g.gradient.padded_over(&g.resolve(bounds), area.abs()) { + Some(padded) => (padded.gradient, padded.geom, padded.capped), + None => (g.gradient.clone(), g.resolve(bounds), false), + }; + // A Perceptual one of spot inks whose ink curve no longer fits with its stops repeated. + if !diamond.method.is_classic() && !diamond.fits_ink_tints() { + return false; + } + let Some(quadrants) = geom.diamond_quadrants(area.abs()) else { return false }; + let gradient = vectorcraft_color::Gradient { kind: GradientKind::Linear, ..diamond }; + let mut drawn = Vec::with_capacity(4); for (outline, geom) in quadrants { let linear = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { gradient: gradient.clone(), geom: Some(geom), ..g.clone() })); let quadrant = kurbo::BezPath::from_vec( @@ -503,16 +540,61 @@ impl Exporter<'_> { .chain([kurbo::PathEl::ClosePath]) .collect(), ); - if let (Some(paint), Some(path)) = (self.paint(s, &linear, bounds), to_path(s, &quadrant)) { - s.set_stroke(None); - s.set_fill(Some(krilla::paint::Fill { paint, opacity: krilla::num::NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); - s.draw_path(&path); - s.set_fill(None); - } + let (Some(paint), Some(path)) = (self.paint(s, &linear, bounds), to_path(s, &quadrant)) else { return false }; + drawn.push((paint, path)); + } + self.warn("diamond gradients are exported as four linear gradients"); + self.warn_capped(capped); + for (paint, path) in drawn { + s.set_stroke(None); + s.set_fill(Some(krilla::paint::Fill { paint, opacity: krilla::num::NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); + s.draw_path(&path); + s.set_fill(None); } true } + /// Draw linear or radial gradient `g`, placed on `bounds`, that repeats or reflects, over + /// `area` (inside the current clip): PDF's axial and radial shadings pad past their ends, so + /// its stops are repeated as many times as the area needs + /// ([`vectorcraft_color::Gradient::padded_over`]). False (nothing drawn) for another gradient, + /// for one whose shading or area PDF can't hold, and for a Perceptual one of spot inks whose + /// ink curve has too many tints once repeated, which [`Self::field_image`] draws instead. + pub(super) fn padded(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect) -> bool { + if !matches!(g.gradient.kind, GradientKind::Linear | GradientKind::Radial) { + return false; + } + let Some(padded) = g.gradient.padded_over(&g.resolve(bounds), area.abs()) else { return false }; + // A Perceptual gradient of spot inks whose ink curve no longer fits once its stops are + // repeated is an image, as one with too many stops is. + if !g.gradient.method.is_classic() && !padded.gradient.fits_ink_tints() { + return false; + } + let capped = padded.capped; + let paint = padded_paint(g, padded); + let (Some(paint), Some(path)) = (self.paint(s, &paint, bounds), to_path(s, &kurbo::Shape::to_path(&area.abs(), 0.1))) else { + return false; + }; + self.warn_capped(capped); + s.set_stroke(None); + s.set_fill(Some(krilla::paint::Fill { paint, opacity: krilla::num::NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); + s.draw_path(&path); + s.set_fill(None); + true + } + + /// Warn when a gradient that repeats or reflects needed more runs of its stops than were + /// written. + fn warn_capped(&mut self, capped: bool) { + if capped { + self.warn(format!( + "gradients that repeat or reflect are exported with at most {} runs of their stops (at most {} stops); they pad past those", + vectorcraft_color::gradient::MAX_SPREAD_RUNS, + vectorcraft_color::gradient::MAX_SPREAD_STOPS + )); + } + } + /// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's /// raster effects resolution where it is drawn (`area` as the current transform sizes it, so /// scaled type gets its pixels too): a freeform gradient's colour field (at most @@ -588,6 +670,12 @@ impl Exporter<'_> { } } +/// `g` written as `padded` gives it: its stops repeated, placed there. +fn padded_paint(g: &vectorcraft_color::GradientPaint, padded: vectorcraft_color::gradient::Padded) -> Paint { + let geom = padded.geom; + Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { gradient: padded.gradient, geom: Some(geom), angle: geom.angle_deg(), ..g.clone() })) +} + /// Is `c` paper white: no ink in CMYK or gray, white in RGB or Lab? fn paper(c: &Color) -> bool { match *c { diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs index 2cafbf010a..2a734174ce 100644 --- a/crates/pdf/src/import_shading.rs +++ b/crates/pdf/src/import_shading.rs @@ -1,5 +1,6 @@ //! Shadings: axial and radial ones become gradients (their stops sampled from the shading -//! function in its own colour model; an end that doesn't extend clips the painted area), a +//! function in its own color model, sharp steps between colors kept where they are; an end that +//! doesn't extend clips the painted area), a //! function-based one whose color changes along one axis a linear gradient, one whose color //! turns around the origin of its space a conic gradient, and one whose color is the same along //! each square about that origin a diamond gradient (else its average color; all of them @@ -41,13 +42,21 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, ink let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0, false).components(); Some(Color::rgb(round3(r), round3(g), round3(b))) }; - // Sample densely, then drop samples that linear interpolation reproduces. + // Sample densely, halving each span between samples where its middle strays from the line + // between them (so a sharp step between two colors, as repeated stops make, is found to + // within [`EDGE_DEPTH`] halvings), then drop samples that linear interpolation reproduces. const N: usize = 64; let mut pts: Vec<(f32, [f32; 3])> = Vec::with_capacity(N + 1); for i in 0..=N { let t = i as f32 / N as f32; - pts.push((t, sample(t)?.to_rgb())); - // An ink that paints only at samples dropped below is still a spot swatch. + let rgb = sample(t)?.to_rgb(); + if let Some(&(t0, c0)) = pts.last() { + refine(&sample, (t0, c0), (t, rgb), 0, &mut pts); + } + pts.push((t, rgb)); + } + // An ink that paints only at samples dropped below is still a spot swatch. + for &(t, _) in &pts { if let Some(v) = at(t) { colors.mark_inks(space, &v); } @@ -92,6 +101,37 @@ fn shading_stop(colors: &mut Colors<'_>, space: Space, comps: Option<&[f32]>, of } } +/// How many times [`refine`] halves a span of an axial or radial shading's samples: to 1/64 × +/// 2^-14 of the axis. +const EDGE_DEPTH: u32 = 14; +/// The most samples of one axial or radial shading [`refine`] takes. +const MAX_EDGE_SAMPLES: usize = 16_384; +/// Where in a span [`refine`] looks a second time when the span's middle lies on the line: at +/// the golden section, which no whole number of repeated steps across the span puts on the same +/// place in a step as the ends. +const SECOND_LOOK: f32 = 0.381_966; + +/// Samples of `sample` between `a` and `b` (offset, RGB), pushed onto `out` in order: the span's +/// middle where it, or the point [`SECOND_LOOK`] along the span, strays from the line between +/// them by more than 1.5 levels, and the samples of each half the same way, at most +/// [`EDGE_DEPTH`] halvings deep and [`MAX_EDGE_SAMPLES`] in all. The second look finds steps that +/// repeat a whole number of times across the span, which put its ends and its middle at the same +/// place in a step. +fn refine(sample: &dyn Fn(f32) -> Option, a: (f32, [f32; 3]), b: (f32, [f32; 3]), depth: u32, out: &mut Vec<(f32, [f32; 3])>) { + if depth >= EDGE_DEPTH || out.len() >= MAX_EDGE_SAMPLES { + return; + } + let on_line = |u: f32, c: [f32; 3]| (0..3).all(|k| (a.1[k] + (b.1[k] - a.1[k]) * u - c[k]).abs() <= 1.5 / 255.0); + let t = (a.0 + b.0) / 2.0; + let Some(c) = sample(t).map(|c| c.to_rgb()) else { return }; + if on_line(0.5, c) && sample(a.0 + (b.0 - a.0) * SECOND_LOOK).is_none_or(|p| on_line(SECOND_LOOK, p.to_rgb())) { + return; + } + refine(sample, a, (t, c), depth + 1, out); + out.push((t, c)); + refine(sample, (t, c), b, depth + 1, out); +} + /// The samples of `pts` (offset, RGB; at least one) a gradient keeps as stops: the first, the /// last, and each one that linear interpolation between its neighbouring stops doesn't reproduce. fn linear_keep(pts: &[(f32, [f32; 3])]) -> Vec { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d8238d8038..94a20146f2 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -45,6 +45,7 @@ mod reader_compat; mod reader_streaming; mod realtext; mod settings; +mod spread; pub(super) mod stroke; mod strokeout; mod text_placement; diff --git a/crates/pdf/src/tests/spread.rs b/crates/pdf/src/tests/spread.rs new file mode 100644 index 0000000000..ebfe655f9a --- /dev/null +++ b/crates/pdf/src/tests/spread.rs @@ -0,0 +1,260 @@ +//! Gradients that repeat or reflect. PDF's axial and radial shadings pad past their ends, so a +//! linear, radial or diamond gradient is written with its stops repeated as many times as the +//! shape needs (axial and radial shadings, which PDF/A allows); a conic's sweep shading runs over +//! one run and repeats or reflects it. Read back, each draws as the canvas draws the gradient. + +use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientSpread, GradientStop, Paint}; +use vectorcraft_doc::{Appearance, Document, Node}; +use vectorcraft_geom::{Point, Rect, Vec2, shapes}; +use vectorcraft_testkit::raster::render_artboard; + +use crate::*; + +/// A page-sized square filled (or stroked 16 pt wide, `stroke`) with a red-to-blue gradient of +/// `kind` placed on `geom`, spreading by `spread` 3 times. +fn doc(kind: GradientKind, spread: GradientSpread, geom: GradientGeom, stroke: bool) -> Document { + doc_n(kind, spread, geom, stroke, 3) +} + +/// [`doc`], spreading `repeats` times. +fn doc_n(kind: GradientKind, spread: GradientSpread, geom: GradientGeom, stroke: bool, repeats: u32) -> Document { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut gp = GradientPaint::new(Gradient { spread, repeats, ..Gradient::new(kind, stops) }); + gp.geom = Some(geom); + let paint = Paint::Gradient(Box::new(gp)); + let ap = if stroke { Appearance::basic(Paint::None, paint, 16.0) } else { Appearance::basic(paint, Paint::None, 0.0) }; + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), ap)).unwrap(); + d +} + +fn cases() -> Vec<(&'static str, GradientKind, GradientGeom)> { + let c = Point::new(100.0, 100.0); + let geom = GradientGeom { start: c, end: c + Vec2::new(60.0, -25.0), aspect: 0.7, focal: None }; + vec![ + ("linear", GradientKind::Linear, GradientGeom { start: Point::new(70.0, 80.0), end: Point::new(150.0, 110.0), aspect: 1.0, focal: None }), + ("radial", GradientKind::Radial, geom), + ("off-center radial", GradientKind::Radial, GradientGeom { focal: Some(c + Vec2::new(-15.0, 12.0)), ..geom }), + ("conic", GradientKind::Conic, geom), + ("diamond", GradientKind::Diamond, geom), + ] +} + +/// Does the PDF text hold an image XObject? +fn image(text: &str) -> bool { + text.contains("/Subtype/Image") || text.contains("/Subtype /Image") +} + +fn uncompressed(d: &Document, o: &PdfOptions) -> String { + String::from_utf8_lossy(&export_with_report(d, o).unwrap().bytes).into_owned() +} + +/// Every kind, filled and stroked, repeating and reflecting, written and read back, looks as the +/// canvas paints it, inside the vector and past its ends. +#[test] +fn spread_gradients_come_back_as_the_canvas_draws_them() { + let _held = super::import_fidelity::SHADINGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for (what, kind, geom) in cases() { + for spread in [GradientSpread::Repeat, GradientSpread::Reflect] { + for stroke in [false, true] { + let d = doc(kind, spread, geom, stroke); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + // Hard edges (where Repeat starts the stops again) differ by a pixel's + // antialiasing at most. + let (a, b) = (render_artboard(&back), render_artboard(&d)); + let st = vectorcraft_testkit::raster::perceptual_diff(&a, &b, 10.0); + assert!( + (st.bad_pixels as f64) < 0.03 * st.pixels as f64, + "{what} {spread:?} stroke {stroke}: {} of {} pixels differ", + st.bad_pixels, + st.pixels + ); + } + } + } +} + +/// A linear, radial or diamond gradient is written as axial or radial shadings (in PDF/A too); a +/// conic as its sweep shading. +#[test] +fn the_shadings_written() { + let mut pdfa = PdfOptions::uncompressed(); + (pdfa.settings.standard, pdfa.settings.compatibility) = (Standard::PdfA2b, Standard::PdfA2b.version()); + for (what, kind, geom) in cases() { + for o in [PdfOptions::uncompressed(), pdfa.clone()] { + let text = uncompressed(&doc(kind, GradientSpread::Reflect, geom, false), &o); + let want = match kind { + GradientKind::Linear | GradientKind::Diamond => "/ShadingType 2", + GradientKind::Radial => "/ShadingType 3", + _ => "/ShadingType 1", + }; + if kind == GradientKind::Conic && o.settings.standard != Standard::None { + // PDF/A has no calculator functions: the sweep is an image. + continue; + } + assert!(text.contains(want), "{what}: {want}"); + assert!(!image(&text), "{what}: no image"); + } + } +} + +/// An axial shading with a sharp step between two colors reads back with the step where it was, +/// not blurred across the samples around it. +#[test] +fn a_sharp_step_in_a_shading_reads_back_sharp() { + let red = Color::rgb(1.0, 0.0, 0.0); + let blue = Color::rgb(0.0, 0.0, 1.0); + let stops = vec![GradientStop::new(0.0, red), GradientStop::new(0.37, red), GradientStop::new(0.37, blue), GradientStop::new(1.0, blue)]; + let mut gp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + gp.geom = Some(GradientGeom { start: Point::new(0.0, 100.0), end: Point::new(200.0, 100.0), aspect: 1.0, focal: None }); + let mut d = Document::new(200.0, 200.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let ap = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), ap)).unwrap(); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let mut found = None; + back.walk(|n| { + if let (None, Paint::Gradient(g)) = (&found, n.appearance.fill_paint()) { + found = Some(*g.clone()); + } + }); + let g = found.expect("a gradient"); + let geom = g.geom.unwrap(); + for (x, want) in [(73.0, red), (72.0, red), (75.0, blue), (76.0, blue)] { + let t = geom.param_at(GradientKind::Linear, Point::new(x, 100.0)); + let got = g.gradient.sample(t as f32).0.to_rgb(); + assert!(got.iter().zip(want.to_rgb()).all(|(a, b)| (a - b).abs() < 0.02), "x {x}: {got:?}"); + } +} + +/// A gradient written with 256 runs of its stops (as many as PDF export writes), so that every +/// sample the reader takes across the shading lands at the start of a run, reads back with its +/// runs rather than as one color. +#[test] +fn many_runs_read_back_as_runs() { + let _held = super::import_fidelity::SHADINGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // A run of 0.4 pt from the shape's left edge, across 160 pt: capped at 256 runs, 102.4 pt. + let geom = GradientGeom { start: Point::new(20.0, 100.0), end: Point::new(60.0, 100.0), aspect: 1.0, focal: None }; + let d = doc_n(GradientKind::Linear, GradientSpread::Repeat, geom, false, 100); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let mut found = None; + back.walk(|n| { + if let (None, Paint::Gradient(g)) = (&found, n.appearance.fill_paint()) { + found = Some(*g.clone()); + } + }); + let g = found.expect("a gradient"); + let geom = g.geom.unwrap(); + for run in [0.0, 1.0, 50.0, 200.0, 255.0] { + for (u, want) in [(0.25, [0.75, 0.0, 0.25]), (0.75, [0.25, 0.0, 0.75])] { + let x = 20.0 + 0.4 * (run + u); + let t = geom.param_at(GradientKind::Linear, Point::new(x, 100.0)); + let got = g.gradient.sample(t as f32).0.to_rgb(); + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.05), "run {run} at {u}: {got:?}"); + } + } +} + +/// A diamond centered in the shape is written in PDF as four linear gradients, each with the runs +/// from the center out to the shape's corner, not padded short of it. +#[test] +fn a_diamond_writes_each_quadrant_from_its_center_out() { + // From the center to a corner: 16 lengths of the vector, 10 runs each. + let c = Point::new(100.0, 100.0); + let geom = GradientGeom { start: c, end: c + Vec2::new(10.0, 0.0), aspect: 1.0, focal: None }; + let d = doc_n(GradientKind::Diamond, GradientSpread::Repeat, geom, false, 10); + let report = export_with_report(&d, &PdfOptions::uncompressed()).unwrap(); + assert!(!report.warnings.iter().any(|w| w.contains("at most 256 runs")), "{:?}", report.warnings); +} + +/// A repeating gradient whose run reaches past the coordinates PDF holds is drawn as an image +/// rather than left out, for a linear, a radial and a diamond gradient. +#[test] +fn a_run_too_long_for_pdf_is_an_image() { + let c = Point::new(100.0, 100.0); + for (what, kind) in [("linear", GradientKind::Linear), ("radial", GradientKind::Radial), ("diamond", GradientKind::Diamond)] { + // Three runs of the vector, each longer than PDF's coordinates reach. + let geom = GradientGeom { start: c, end: c + Vec2::new(1.2e10, 0.0), aspect: 1.0, focal: None }; + let text = uncompressed(&doc(kind, GradientSpread::Repeat, geom, false), &PdfOptions::uncompressed()); + assert!(image(&text), "{what}: drawn as an image"); + } +} + +/// A Perceptual gradient of a spot ink stays Separation shadings in that ink while its repeated +/// stops' ink curve fits, and is an image (rather than tints mixed the Classic way) once it no +/// longer does, for a linear and a diamond gradient. +#[test] +fn a_perceptual_spot_gradient_too_long_once_repeated_is_an_image() { + let gold = Color::cmyk(0.0, 0.2, 0.8, 0.1); + // Gold and paper white in turn: a curve of many tints in each run. + let stops: Vec = (0..=4) + .map(|i| match i % 2 { + 0 => { + let mut s = GradientStop::new(i as f32 / 4.0, gold); + (s.swatch, s.tint) = (Some("Gold".into()), 1.0); + s + } + _ => GradientStop::new(i as f32 / 4.0, Color::cmyk(0.0, 0.0, 0.0, 0.0)), + }) + .collect(); + let c = Point::new(100.0, 100.0); + for (kind, short, long) in [ + ( + GradientKind::Linear, + GradientGeom { start: Point::new(20.0, 100.0), end: Point::new(20.4, 100.0), aspect: 1.0, focal: None }, + GradientGeom { start: Point::new(20.0, 100.0), end: Point::new(180.0, 100.0), aspect: 1.0, focal: None }, + ), + ( + GradientKind::Diamond, + GradientGeom { start: c, end: c + Vec2::new(0.3, 0.0), aspect: 1.0, focal: None }, + GradientGeom { start: c, end: c + Vec2::new(80.0, 0.0), aspect: 1.0, focal: None }, + ), + ] { + for (geom, repeats, image_wanted) in [(long, 2, false), (short, 1, true)] { + let g = Gradient { + method: vectorcraft_color::GradientMethod::Perceptual, + spread: GradientSpread::Repeat, + repeats, + ..Gradient::new(kind, stops.clone()) + }; + let padded = g.padded_over(&geom, Rect::new(20.0, 20.0, 180.0, 180.0)).unwrap(); + assert!(g.fits_ink_tints() && padded.gradient.fits_ink_tints() != image_wanted, "{kind:?} {repeats}"); + let mut gp = GradientPaint::new(g); + gp.geom = Some(geom); + let mut d = Document::new(200.0, 200.0); + d.color_mode = vectorcraft_doc::ColorMode::Cmyk; + d.swatches.push(vectorcraft_color::Swatch { name: "Gold".into(), paint: Paint::solid(gold), global: true, spot: true }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let ap = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), ap)).unwrap(); + let text = uncompressed(&d, &PdfOptions::uncompressed()); + assert_eq!(image(&text), image_wanted, "{kind:?} {repeats}"); + assert_eq!(text.contains("[/Separation/Gold"), !image_wanted, "{kind:?} {repeats}"); + } + } +} + +/// A gradient that repeats with no stops (a file can hold one) exports, filled and stroked. +#[test] +fn a_spread_gradient_without_stops_exports() { + for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond] { + let g = GradientPaint::new(Gradient { spread: GradientSpread::Repeat, repeats: 3, ..Gradient::new(kind, vec![]) }); + let paint = Paint::Gradient(Box::new(g)); + let mut d = Document::new(200.0, 200.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let ap = Appearance::basic(paint.clone(), paint, 4.0); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), ap)).unwrap(); + export(&d, &PdfOptions::default()).unwrap(); + } +} + +/// A run far too short for the shape is written with at most 256 runs, padding past them, with a +/// warning. +#[test] +fn far_repeats_are_capped_with_a_warning() { + let geom = GradientGeom { start: Point::new(20.0, 20.0), end: Point::new(20.5, 20.0), aspect: 1.0, focal: None }; + let report = export_with_report(&doc(GradientKind::Linear, GradientSpread::Repeat, geom, false), &PdfOptions::uncompressed()).unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("at most 256 runs")), "{:?}", report.warnings); +} diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 0e149579f5..98cf893dee 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -616,6 +616,7 @@ fn decode_paint(v: &Value, at: &str) -> Result { } stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); g.angle = g.angle.rem_euclid(360.0); + g.gradient.repeats = g.gradient.repeat_count(); let conic = g.gradient.kind == vectorcraft_color::GradientKind::Conic; let no_focal = conic || g.gradient.kind == vectorcraft_color::GradientKind::Diamond; if let Some(geom) = &mut g.geom { diff --git a/crates/render/src/dither.rs b/crates/render/src/dither.rs index dfd2f53927..0bcc48a17f 100644 --- a/crates/render/src/dither.rs +++ b/crates/render/src/dither.rs @@ -58,7 +58,7 @@ fn cells(px: f64) -> u16 { fn key(g: &GradientPaint, place: Rect, b: Rect, cols: u16, rows: u16, win: &[Range; 2], ink: Ink, fill: bool) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); let gr = &g.gradient; - (gr.kind as u8, gr.dither && fill, gr.method as u8).hash(&mut h); + (gr.kind as u8, gr.dither && fill, gr.method as u8, gr.painted_spread()).hash(&mut h); for s in &gr.stops { (s.offset.to_bits(), s.opacity.to_bits(), s.midpoint.to_bits()).hash(&mut h); s.color.key().hash(&mut h); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index dd83e4cee7..1d39ece10c 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -19,7 +19,15 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr } Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => crate::freeform::set_freeform_paint(ctx, g, bounds, f.ink), Paint::Gradient(g) => { - let geom = g.resolve(bounds); + // A gradient that repeats or reflects: one run of its stops over its period, which + // the renderer extends past both ends. + let spread = g.gradient.is_spread(); + let geom = if spread { g.resolve(bounds).period(g.gradient.kind, g.gradient.repeat_count()) } else { g.resolve(bounds) }; + let extend = match g.gradient.spread { + _ if !spread => peniko::Extend::Pad, + vectorcraft_color::GradientSpread::Reflect => peniko::Extend::Reflect, + _ => peniko::Extend::Repeat, + }; let adjusted = f.ink.gradient(&g.gradient); let gradient = adjusted.as_ref().unwrap_or(&g.gradient); let ink = if adjusted.is_some() { f.ink.base() } else { f.ink }; @@ -42,9 +50,11 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr } GradientKind::Conic => { // One turn about the origin, carried onto the extent ellipse: its frame starts - // the sweep along the vector, and a mirrored frame reverses it. + // the sweep along the vector, and a mirrored frame reverses it. Repeated `n` + // times, one run takes 1/n of the turn. ctx.set_paint_transform(geom.conic_frame()); - peniko::Gradient::new_sweep((0.0, 0.0), 0.0, std::f32::consts::TAU).with_stops(stops.as_slice()) + let turn = if spread { std::f32::consts::TAU / g.gradient.repeat_count() as f32 } else { std::f32::consts::TAU }; + peniko::Gradient::new_sweep((0.0, 0.0), 0.0, turn).with_stops(stops.as_slice()) } // peniko has no diamond gradient: it is drawn as pixels, as a dithered fill is. GradientKind::Diamond => return crate::dither::set_dithered_paint(ctx, g, bounds, bounds, f.ink, false), @@ -57,7 +67,7 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr }; // Colour and opacity blend each on its own, as the model samples them and as the PDF // and SVG exports draw them (peniko's default premultiplies). - ctx.set_paint(grad.with_interpolation_alpha_space(peniko::InterpolationAlphaSpace::Unpremultiplied)); + ctx.set_paint(grad.with_extend(extend).with_interpolation_alpha_space(peniko::InterpolationAlphaSpace::Unpremultiplied)); true } Paint::Pattern { pattern, xf } => crate::pattern::set_pattern_paint(ctx, pattern, *xf, f), diff --git a/crates/render/src/renderer/paint.rs b/crates/render/src/renderer/paint.rs index 2bf0d165db..1d61a2656c 100644 --- a/crates/render/src/renderer/paint.rs +++ b/crates/render/src/renderer/paint.rs @@ -212,11 +212,12 @@ impl Renderer { let tol = 2f64.powi(level); // Reach a pixel past the stroke, so its antialiased edge has colour under it. let slices = effects::stroke::gradient_slices(bp, rule, st, tol, f.px + tol, f.px); + let unrolled = g.unrolled(); Arc::new( slices .into_iter() .map(|s| { - let (b, paint) = (s.shape.bounding_box(), s.paint(g)); + let (b, paint) = (s.shape.bounding_box(), s.paint(&unrolled)); (s.shape, paint, b) }) .collect(), diff --git a/crates/render/src/tests/strokegradient.rs b/crates/render/src/tests/strokegradient.rs index 961f18590f..f286a04252 100644 --- a/crates/render/src/tests/strokegradient.rs +++ b/crates/render/src/tests/strokegradient.rs @@ -47,6 +47,18 @@ fn along_a_quarter_of_the_length_is_a_quarter_of_the_gradient_in_any_direction() } } +/// A gradient that reflects twice runs its stops twice along the stroke, the second run +/// backwards. +#[test] +fn along_a_reflecting_gradient_runs_its_stops_that_many_times() { + let g = Gradient { spread: vectorcraft_color::GradientSpread::Reflect, repeats: 2, ..Gradient::default() }; + let img = render(&line((20.0, 100.0), (180.0, 100.0)), g, StrokeGradientMode::Along, true); + for (f, want) in [(0.125, 0.75), (0.375, 0.25), (0.625, 0.25), (0.875, 0.75)] { + let x = 20.0 + 160.0 * f; + assert!((grey(&img, x, 100.0) - want).abs() < 0.03, "at {f}: {} vs {want}", grey(&img, x, 100.0)); + } +} + #[test] fn across_runs_from_the_left_edge_to_the_right() { // Travelling right, the left edge is the top one (y 90), the right edge the bottom (y 110). diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 9a9e059fba..66d61e9bbb 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -9,4 +9,5 @@ mod golden; mod gradient_alpha; mod gradient_text; mod radial_focal; +mod spread; mod stroke_geometry; diff --git a/crates/render/tests/it/spread.rs b/crates/render/tests/it/spread.rs new file mode 100644 index 0000000000..9e0c73d545 --- /dev/null +++ b/crates/render/tests/it/spread.rs @@ -0,0 +1,93 @@ +//! Gradients that repeat or reflect: the canvas draws each kind's stops `repeats` times along its +//! vector and on past its ends, as the model's parameter gives them, on fills and strokes. + +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientSpread, Paint}; +use vectorcraft_doc::{Appearance, Document}; +use vectorcraft_geom::{Point, Rect, Vec2, shapes}; +use vectorcraft_testkit::fixtures::DocBuilder; +use vectorcraft_testkit::raster::{Image, render_view}; + +/// A white-to-black gradient of `kind` on `geom` that spreads by `spread`, twice. +fn gradient(kind: GradientKind, spread: GradientSpread, geom: GradientGeom, dither: bool) -> GradientPaint { + let mut gp = GradientPaint::new(Gradient { kind, spread, repeats: 2, dither, ..Gradient::default() }); + gp.geom = Some(geom); + gp +} + +/// A 100 pt square filled with `fill` and stroked `width` wide with `stroke`. +fn square(fill: Paint, stroke: Paint, width: f64) -> Document { + let mut b = DocBuilder::new(100.0, 100.0); + b.path(shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), Appearance::basic(fill, stroke, width), |_| {}); + b.build() +} + +/// Every 5th pixel inside `inside`, away from where Repeat jumps from black back to white and +/// from a conic's center, has the gray the model gives it (pixel centers), within what a pixel's +/// step on the ramp and dither's noise allow; at least `least` pixels are checked. +fn assert_follows_model(img: &Image, g: &GradientPaint, inside: Rect, least: usize, what: &str) { + let geom = g.geom.unwrap(); + let kind = g.gradient.kind; + let mut checked = 0; + for y in (2..100).step_by(5) { + for x in (2..100).step_by(5) { + let p = Point::new(x as f64 + 0.5, y as f64 + 0.5); + if !inside.contains(p) || (kind == GradientKind::Conic && p.distance(geom.start) < 12.0) { + continue; + } + let at = |q: Point| g.gradient.spread_param(geom.param_at(kind, q)); + let t = at(p); + if g.gradient.spread == GradientSpread::Repeat && !(0.06..0.94).contains(&t) { + continue; + } + // How far the gray moves across a pixel there: pixels drawn from a grid of cells (a + // diamond's, a dithered fill's) are sampled up to half a pixel away. + let step = [Vec2::new(0.5, 0.0), Vec2::new(0.0, 0.5), Vec2::new(-0.5, 0.0), Vec2::new(0.0, -0.5)] + .map(|d| 2.0 * (at(p + d) - t).abs()) + .into_iter() + .fold(0.0, f64::max); + let tolerance = 6 + (step * 255.0).ceil() as u8; + let want = ((1.0 - t.clamp(0.0, 1.0)) * 255.0).round() as u8; + let got = img.over_white(x, y)[0]; + assert!(got.abs_diff(want) <= tolerance, "{what} ({x},{y}) t={t:.3}: got {got}, want {want} (within {tolerance})"); + checked += 1; + } + } + assert!(checked >= least, "{what}: only {checked} pixels checked"); +} + +fn geoms() -> [(GradientKind, GradientGeom); 5] { + let c = Point::new(50.0, 50.0); + let level = GradientGeom { start: c, end: c + Vec2::new(36.0, 12.0), aspect: 0.7, focal: None }; + [ + (GradientKind::Linear, GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(70.0, 45.0), aspect: 1.0, focal: None }), + (GradientKind::Radial, level), + (GradientKind::Radial, GradientGeom { focal: Some(c + Vec2::new(-10.0, 6.0)), ..level }), + (GradientKind::Conic, level), + (GradientKind::Diamond, level), + ] +} + +/// Linear, radial (centered and off-center), conic and diamond fills repeat and reflect as the +/// model says, inside the vector and past its ends; so does a dithered fill, drawn as pixels. +#[test] +fn fills_repeat_and_reflect_for_every_kind() { + for (kind, geom) in geoms() { + for spread in [GradientSpread::Repeat, GradientSpread::Reflect] { + for dither in [false, true] { + let g = gradient(kind, spread, geom, dither); + let img = render_view(&square(Paint::Gradient(Box::new(g.clone())), Paint::None, 0.0), 100, 100); + assert_follows_model(&img, &g, Rect::new(12.0, 12.0, 88.0, 88.0), 100, &format!("{kind:?} {spread:?} dither {dither}")); + } + } + } +} + +/// A stroke's gradient repeats out to its miters too. +#[test] +fn a_stroke_repeats_out_past_the_paint_box() { + for (kind, geom) in geoms() { + let g = gradient(kind, GradientSpread::Reflect, geom, false); + let img = render_view(&square(Paint::None, Paint::Gradient(Box::new(g.clone())), 12.0), 100, 100); + assert_follows_model(&img, &g, Rect::new(5.0, 5.0, 15.0, 95.0), 20, &format!("{kind:?} stroke")); + } +} diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index ada9a4375c..bdda3a65a9 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -7,7 +7,7 @@ use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; -use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint}; +use vectorcraft_color::{BlendMode, Color, GradientGeom, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{AppearanceItem, CharStyle, Document, Justify, LineCap, LiveShape, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind}; use vectorcraft_effects::RasterFx; use vectorcraft_geom::shapes::{self, CornerKind}; @@ -559,9 +559,25 @@ impl Rules<'_> { /// writes (midpoints as stops of their own). `None` for freeform gradients. fn gradient(&mut self, g: &GradientPaint, b: Rect, opacity: f32) -> Option { let geom = g.resolve(b); + // A linear, radial or diamond gradient that repeats or reflects: a repeating gradient of + // one cycle (a run, or a run and its mirror) over that cycle's placement + // (https://www.w3.org/TR/css-images-3/#repeating-gradients). A conic one: its stops + // repeated around the turn. + let kind = g.gradient.kind; + let unrolled = g.gradient.unrolled(); + let (gradient, geom, repeating) = match g.gradient.cycle() { + Some((cycle, runs)) if kind != GradientKind::Conic => { + // CSS radials start at their center: the cycle is scaled about it. + let centered = GradientGeom { focal: None, ..geom }; + let cycle_geom = centered.period(kind, g.gradient.repeat_count()).levels_scaled(kind, f64::from(runs)); + let geom = GradientGeom { focal: geom.focal, ..cycle_geom }; + (std::borrow::Cow::Owned(cycle), geom, "repeating-") + } + _ => (unrolled, geom, ""), + }; let stops = |pos: &dyn Fn(f32) -> f64| { let stops: Vec = - g.gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, 2))).collect(); + gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, 2))).collect(); stops.join(", ") }; match g.gradient.kind { @@ -578,7 +594,7 @@ impl Rules<'_> { let line = (b.width() * dir.x).abs() + (b.height() * dir.y).abs(); let from = (geom.start - b.center()).dot(dir); let pos = |t: f32| if line > 1e-9 { (from + t as f64 * length) / line + 0.5 } else { t as f64 }; - Some(format!("linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos))) + Some(format!("{repeating}linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos))) } // CSS has no diamond gradient: the ellipse through the square's corners stands in. GradientKind::Radial | GradientKind::Diamond => { @@ -594,7 +610,7 @@ impl Rules<'_> { format!("ellipse {} {}", self.len(r), self.len(r * geom.aspect)) }; let at = format!("at {} {}", self.len(geom.start.x - b.x0), self.len(geom.start.y - b.y0)); - Some(format!("radial-gradient({shape} {at}, {})", stops(&|t| t as f64))) + Some(format!("{repeating}radial-gradient({shape} {at}, {})", stops(&|t| t as f64))) } GradientKind::Conic => { // CSS sweeps clockwise from the `from` angle, 0deg straight up @@ -607,7 +623,7 @@ impl Rules<'_> { if (geom.aspect.abs() - 1.0).abs() > 1e-9 && geom.unit_frame().is_some() { self.warn("conic gradients are written circular (an elliptical sweep is left out)"); } - let mut stops: Vec<(f32, Color, f32)> = g.gradient.expanded().map(|(t, c, o, _)| (t, c, o)).collect(); + let mut stops: Vec<(f32, Color, f32)> = gradient.expanded().map(|(t, c, o, _)| (t, c, o)).collect(); if geom.is_mirrored() { stops.reverse(); stops.iter_mut().for_each(|s| s.0 = 1.0 - s.0); diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs index e982ab315c..515e556a4b 100644 --- a/crates/svg/src/export/paint.rs +++ b/crates/svg/src/export/paint.rs @@ -273,7 +273,15 @@ impl Writer<'_> { // Object fills and strokes are four linear gradients or images (see `raster_paint`). self.warn("diamond gradients on characters are written as radial gradients"); } + // A gradient that repeats or reflects: one run of its stops over its period, and + // `spreadMethod` to carry it on past both ends. + let spread = g.gradient.is_spread(); let mut geom = g.resolve(bounds); + if spread { + // A conic is written as a linear gradient along its vector: its runs lie along it. + let written = if g.gradient.kind == GradientKind::Conic { GradientKind::Linear } else { g.gradient.kind }; + geom = geom.period(written, g.gradient.repeat_count()); + } geom.transform(self.xf, g.gradient.kind); if g.gradient.kind != GradientKind::Radial { // Only a radial gradient has a focal point (a plug-in's or a file's other kinds can @@ -304,6 +312,11 @@ impl Writer<'_> { format!(" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", self.num(s.x), self.num(s.y), self.num(e.x), self.num(e.y)), ), }; + let attrs = match g.gradient.spread { + _ if !spread => attrs, + vectorcraft_color::GradientSpread::Reflect => format!("{attrs} spreadMethod=\"reflect\""), + _ => format!("{attrs} spreadMethod=\"repeat\""), + }; let stops: Vec = g .gradient .expanded() @@ -558,10 +571,10 @@ impl Writer<'_> { } } None => { - let sample = g.gradient.sampler(); - for (pts, t) in g.resolve(bounds).conic_wedges(cover, CONIC_WEDGES, true) { + let (sample, spread) = (g.gradient.sampler(), g.gradient.spreader()); + for (pts, t) in g.resolve(bounds).conic_wedges(cover, g.gradient.conic_wedge_count(CONIC_WEDGES), true) { let d = self.path_d(&PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)), self.xf); - self.line(&format!("", sample(t as f32).0.to_hex())); + self.line(&format!("", sample(spread(t) as f32).0.to_hex())); } } }, diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 47fcfbaf14..4ac3530b19 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -49,7 +49,6 @@ mod depth; mod files; mod fx; mod linear_rgb; -mod spread; mod symbol_units; mod text; mod vector_effect; diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index ca31f28b85..33878ac998 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -4,10 +4,11 @@ use super::prepass::link; use super::{ Affine, Appearance, AppearanceItem, Arc, BlendMode, Color, Dash, FillLayer, FillRule, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, ImageBlob, ImageObject, Importer, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, PathData, PatternDef, Point, Rect, Reuse, - StrokeLayer, Vec2, fnv1a, fx, shapes, spread, text, vector_effect, + StrokeLayer, Vec2, fnv1a, fx, shapes, text, vector_effect, }; use crate::SvgError; use kurbo::BezPath; +use vectorcraft_color::GradientSpread; /// How many clip paths deep (``) an object's clip is read. const MAX_CLIP_NEST: usize = 16; @@ -246,10 +247,7 @@ impl Importer { // space; runs paint in text space. let to_text = Affine::scale(grow) * obj.xf.inverse(); let slot_paint = |im: &mut Self, k: usize, m: Affine| match kids.get(main + 1 + k) { - Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| { - let area = kurbo::Shape::bounding_box(&bezpath(p.data(), m)); - im.paint(f.paint(), m, Some(area)) - }), + Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| im.paint(f.paint(), m)), _ => Paint::None, }; let resolved: Vec = (0..paints).map(|k| slot_paint(self, k, to_text)).collect(); @@ -608,9 +606,8 @@ impl Importer { Some(NodeKind::Compound { children, rule: r }) } - /// A usvg paint mapped by `m`, for art covering `area` (after `m`: where a reflected or - /// repeated gradient has to reach). - fn paint(&mut self, p: &usvg::Paint, m: Affine, area: Option) -> Paint { + /// A usvg paint mapped by `m`. + fn paint(&mut self, p: &usvg::Paint, m: Affine) -> Paint { let (kind, base, mut geom) = match p { usvg::Paint::Color(c) => return Paint::solid(Color::rgb8(c.red, c.green, c.blue)), usvg::Paint::Pattern(pt) => return self.pattern(pt, m), @@ -637,28 +634,14 @@ impl Importer { } }; geom.transform(m * aff(base.transform()), kind); - let mut stops = self.stops(base); - let reflect = match base.spread_method() { - usvg::SpreadMethod::Pad => None, - usvg::SpreadMethod::Reflect => Some(true), - usvg::SpreadMethod::Repeat => Some(false), + let mut gradient = Gradient::new(kind, self.stops(base)); + // `spreadMethod` is the gradient's spread; its stops run once along the vector. + gradient.spread = match base.spread_method() { + usvg::SpreadMethod::Pad => GradientSpread::Pad, + usvg::SpreadMethod::Reflect => GradientSpread::Reflect, + usvg::SpreadMethod::Repeat => GradientSpread::Repeat, }; - if let Some(reflect) = reflect { - match area.map(|a| spread::expand(kind, &mut geom, &mut stops, reflect, a)) { - Some(spread::Expanded::Whole) => {} - Some(spread::Expanded::Capped) => { - self.warn(format!("gradient '{}': spreadMethod repeats more than {} times; it pads after that", base.id(), spread::MAX_PERIODS)) - } - None => self.warn(format!("gradient '{}': spreadMethod approximated as pad", base.id())), - } - } - Paint::Gradient(Box::new(GradientPaint { - gradient: Gradient::new(kind, stops), - geom: Some(geom), - angle: geom.angle_deg(), - swatch: None, - freeform: None, - })) + Paint::Gradient(Box::new(GradientPaint { gradient, geom: Some(geom), angle: geom.angle_deg(), swatch: None, freeform: None })) } /// `` → a pattern swatch painted with the pattern's tile placement. The swatch art is @@ -692,12 +675,12 @@ impl Importer { Paint::Pattern { pattern: name, xf } } - /// The fill and stroke of `p` mapped by `m`; `area`: the bounds of the path after `m`. - fn appearance(&mut self, p: &usvg::Path, m: Affine, area: Option) -> (Appearance, FillRule) { + /// The fill and stroke of `p` mapped by `m`. + fn appearance(&mut self, p: &usvg::Path, m: Affine) -> (Appearance, FillRule) { let mut fill = None; let mut r = FillRule::NonZero; if let Some(f) = p.fill() { - let paint = self.paint(f.paint(), m, area); + let paint = self.paint(f.paint(), m); let mut fl = FillLayer::new(paint); fl.opacity = f.opacity().get(); r = rule(f.rule()); @@ -708,9 +691,7 @@ impl Importer { // A non-scaling stroke is as wide on screen whatever `m` does: the path takes `m`, so a // stroke of that width in the document is exact. let scale = if self.non_scaling.contains(p.id()) { self.screen } else { m.determinant().abs().sqrt() }; - // The stroke reaches past the path (miters further: a generous margin). - let reach = s.width().get() as f64 * scale * (s.miterlimit().get() as f64).max(1.0); - let paint = self.paint(s.paint(), m, area.map(|a| a.inflate(reach, reach))); + let paint = self.paint(s.paint(), m); let mut sl = StrokeLayer::new(paint, s.width().get() as f64 * scale); sl.opacity = s.opacity().get(); sl.cap = match s.linecap() { @@ -749,7 +730,7 @@ impl Importer { if path.is_empty() { return None; } - let (appearance, r) = self.appearance(p, acc, path.bounds()); + let (appearance, r) = self.appearance(p, acc); // Shared, so a compound path's subpaths don't each copy a gradient's stops. let appearance: vectorcraft_doc::Shared = appearance.into(); let mut n = if path.subpaths.len() > 1 { diff --git a/crates/svg/src/import/spread.rs b/crates/svg/src/import/spread.rs deleted file mode 100644 index 1c6c1f3ec4..0000000000 --- a/crates/svg/src/import/spread.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! `spreadMethod="reflect"` and `"repeat"`: gradients only pad past their ends, so the stops are -//! repeated (every other period mirrored, for reflect) as many times as the painted area needs, -//! and the gradient stretched over those periods. - -use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop}; -use vectorcraft_geom::{Point, Rect}; - -/// Most periods a gradient is expanded to. -pub(super) const MAX_PERIODS: usize = 64; - -/// What [`expand`] did. -#[derive(Debug, PartialEq, Eq)] -pub(super) enum Expanded { - /// Every period the area shows. - Whole, - /// [`MAX_PERIODS`] of them: the gradient pads past those. - Capped, -} - -/// Expand `stops` and `geom` (document space) so the gradient paints `area` as the spread method -/// does: `reflect` mirrors every other period, else they repeat. -pub(super) fn expand(kind: GradientKind, geom: &mut GradientGeom, stops: &mut Vec, reflect: bool, area: Rect) -> Expanded { - let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)]; - let ts = corners.map(|p| geom.param_at(kind, p)); - if ts.iter().any(|t| !t.is_finite()) || stops.is_empty() { - return Expanded::Whole; - } - let radial = kind == GradientKind::Radial; - // The parameter's range over the area: a corner holds its extremes (radial levels are nested - // convex rings from the focal point, where the parameter is 0). - let lo = if radial { 0.0 } else { ts.iter().copied().fold(f64::INFINITY, f64::min).floor() }; - let hi = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max).ceil().max(lo + 1.0); - if lo >= 0.0 && hi <= 1.0 { - return Expanded::Whole; - } - let capped = hi - lo > MAX_PERIODS as f64; - // Capped, the periods kept start no further back than half of them. - let lo = if capped { lo.max(-(MAX_PERIODS as f64) / 2.0) } else { lo }; - let n = (hi - lo).min(MAX_PERIODS as f64); - let period = whole_period(stops); - let mut out = Vec::with_capacity(period.len() * n as usize); - for j in 0..n as usize { - let mirrored = reflect && (lo as i64 + j as i64).rem_euclid(2) == 1; - let one = if mirrored { mirror(&period) } else { period.clone() }; - out.extend(one.into_iter().map(|s| GradientStop { offset: ((j as f64 + s.offset as f64) / n) as f32, ..s })); - } - *stops = out; - let u = geom.end - geom.start; - if radial { - // Level t of an off-centre radial is the extent ellipse scaled by t about the line from the - // focal point to the centre; level n·t' of the new one: the centre moves away from the - // focal point n times as far, and the radius grows n times. - let Some(frame) = geom.unit_frame() else { return Expanded::Whole }; - let f = geom.focal.map_or(Point::ORIGIN, |f| frame.inverse() * f); - geom.start = frame * (f.to_vec2() * (1.0 - n)).to_point(); - geom.end = geom.start + u * n; - } else { - let start = geom.start; - geom.start = start + u * lo; - geom.end = start + u * (lo + n); - } - if capped { Expanded::Capped } else { Expanded::Whole } -} - -/// The stops of one period, from offset 0 to 1: the end colours held out to the ends. -fn whole_period(stops: &[GradientStop]) -> Vec { - let mut out = stops.to_vec(); - if let Some(first) = out.first().filter(|s| s.offset > 0.0).cloned() { - out.insert(0, GradientStop { offset: 0.0, ..first }); - } - if let Some(last) = out.last_mut() { - // The last stop's midpoint leads nowhere; the copy at 1 takes over. - if last.offset < 1.0 { - let end = GradientStop { offset: 1.0, ..last.clone() }; - out.push(end); - } - } - out -} - -/// A period mirrored: the stops in reverse, each midpoint measured from the other side. -fn mirror(period: &[GradientStop]) -> Vec { - let mut g = Gradient::new(GradientKind::Linear, period.to_vec()); - g.reverse(); - g.stops -} - -#[cfg(test)] -mod tests { - use super::*; - use vectorcraft_color::Color; - - fn stops() -> Vec { - let mut a = GradientStop::new(0.0, Color::BLACK); - a.midpoint = 0.3; - vec![a, GradientStop::new(1.0, Color::WHITE)] - } - - #[test] - fn a_linear_gradient_repeats_over_the_area() { - let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; - let mut s = stops(); - let r = expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(-5.0, 0.0, 25.0, 10.0)); - assert_eq!(r, Expanded::Whole); - // From -10 to 30: four periods. - assert_eq!((geom.start, geom.end), (Point::new(-10.0, 0.0), Point::new(30.0, 0.0))); - let offsets: Vec = s.iter().map(|s| s.offset).collect(); - assert_eq!(offsets, [0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0]); - assert!(s.iter().step_by(2).all(|s| s.color == Color::BLACK && (s.midpoint - 0.3).abs() < 1e-6)); - } - - #[test] - fn reflect_mirrors_every_other_period_and_its_midpoints() { - let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; - let mut s = stops(); - expand(GradientKind::Linear, &mut geom, &mut s, true, Rect::new(0.0, 0.0, 20.0, 10.0)); - let colours: Vec = s.iter().map(|s| s.color).collect(); - assert_eq!(colours, [Color::BLACK, Color::WHITE, Color::WHITE, Color::BLACK]); - // Black → white with the midpoint at 0.3 mirrors to white → black with it at 0.7. - assert!((s[2].midpoint - 0.7).abs() < 1e-6, "{s:?}"); - } - - #[test] - fn stops_inside_the_period_hold_their_colours_to_its_ends() { - let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; - let mut s = vec![GradientStop::new(0.25, Color::BLACK), GradientStop::new(0.75, Color::WHITE)]; - expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 20.0, 10.0)); - let offsets: Vec = s.iter().map(|s| s.offset).collect(); - assert_eq!(offsets, [0.0, 0.125, 0.375, 0.5, 0.5, 0.625, 0.875, 1.0]); - } - - #[test] - fn a_radial_gradient_grows_about_its_focal_point() { - let c = Point::new(50.0, 50.0); - let mut geom = GradientGeom { start: c, end: Point::new(60.0, 50.0), aspect: 1.0, focal: Some(Point::new(55.0, 50.0)) }; - let before = geom.param_at(GradientKind::Radial, Point::new(72.0, 61.0)); - let mut s = stops(); - expand(GradientKind::Radial, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 100.0, 100.0)); - let n = s.len() / 2; - assert!(n >= 5, "{n}"); - assert_eq!(geom.focal, Some(Point::new(55.0, 50.0))); - // Every point keeps its level: the new parameter is the old one over n. - let after = geom.param_at(GradientKind::Radial, Point::new(72.0, 61.0)); - assert!((after * n as f64 - before).abs() < 1e-9, "{before} {after}"); - } - - #[test] - fn far_repeats_are_capped() { - let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None }; - let mut s = stops(); - assert_eq!(expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 100.0, 1.0)), Expanded::Capped); - assert_eq!(s.len(), 2 * MAX_PERIODS); - } -} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 8a447f6c38..066392bb87 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -74,8 +74,8 @@ //! * A `` layer (top-level, or inside one) that isn't displayed (`display: none`) comes back //! as a hidden layer; other undisplayed objects as hidden objects, unless a `` refers to //! them. -//! * `spreadMethod` reflect and repeat are expanded into stops over the painted area (at most -//! 64 periods). +//! * `spreadMethod` reflect and repeat become the gradient's spread, Reflect or Repeat, its stops +//! running once along the vector. //! * `` files are read through [`ImportOptions`]: rasters stay linked, SVG files (and SVG //! `data:` URLs) become art (without their text), missing ones a placeholder keeping the link. //! * Text lines after the first start at the first line's x; absolute positions inside type on a diff --git a/crates/svg/tests/it/css_rules.rs b/crates/svg/tests/it/css_rules.rs index 0c1af2d287..2ca6dcc30b 100644 --- a/crates/svg/tests/it/css_rules.rs +++ b/crates/svg/tests/it/css_rules.rs @@ -335,6 +335,45 @@ fn diamond_gradients_give_css_radial_gradients_through_their_corners() { ); } +/// A linear or radial gradient that repeats or reflects is a repeating CSS gradient of one cycle (a +/// run, or for Reflect a run and its mirror); a conic one has its stops repeated around the turn. +#[test] +fn spread_gradients_give_repeating_css_gradients() { + use vectorcraft_color::GradientSpread; + let mut d = doc(); + let mut css = |kind, spread, geom: GradientGeom| { + let mut g = GradientPaint::new(Gradient { kind, spread, repeats: 4, ..Gradient::default() }); + g.geom = Some(geom); + let mut n = rect(&mut d, (0.0, 0.0, 100.0, 60.0), 0.0); + n.appearance = Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0).into(); + let id = add(&mut d, n); + prop(&one(&d, id, &CssOptions::default()), "background-image").map(str::to_string) + }; + // Left to right across the box: a run is a quarter of it. + let across = GradientGeom { start: Point::new(0.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }; + assert_eq!( + css(GradientKind::Linear, GradientSpread::Repeat, across).as_deref(), + Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 25%)") + ); + assert_eq!( + css(GradientKind::Linear, GradientSpread::Reflect, across).as_deref(), + Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 25%, #000000 25%, #ffffff 50%)") + ); + let ring = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None }; + assert_eq!( + css(GradientKind::Radial, GradientSpread::Reflect, ring).as_deref(), + Some("repeating-radial-gradient(circle 20px at 40px 30px, #ffffff 0%, #000000 50%, #000000 50%, #ffffff 100%)") + ); + // A diamond's stand-in, the ellipse through its corners, repeats with each run of squares. + assert_eq!( + css(GradientKind::Diamond, GradientSpread::Repeat, ring).as_deref(), + Some("repeating-radial-gradient(circle 10px at 40px 30px, #ffffff 0%, #000000 100%)") + ); + let conic = css(GradientKind::Conic, GradientSpread::Repeat, ring).unwrap(); + assert!(conic.starts_with("conic-gradient(from 90deg at 40px 30px, #ffffff 0%, #000000 25%, #ffffff 25%"), "{conic}"); + assert!(conic.ends_with("#000000 100%)"), "{conic}"); +} + #[test] fn shadows_and_unsupported_art() { let mut d = doc(); diff --git a/crates/svg/tests/it/main.rs b/crates/svg/tests/it/main.rs index ac0b2442ec..b241f2e75b 100644 --- a/crates/svg/tests/it/main.rs +++ b/crates/svg/tests/it/main.rs @@ -32,6 +32,7 @@ mod options; mod prop_svg; mod raster_paints; mod roundtrip; +mod spread; mod stroke_export; mod svg_glyphs; mod symbols_layers; diff --git a/crates/svg/tests/it/spread.rs b/crates/svg/tests/it/spread.rs new file mode 100644 index 0000000000..9a7cc0739d --- /dev/null +++ b/crates/svg/tests/it/spread.rs @@ -0,0 +1,132 @@ +//! Gradients that repeat or reflect: SVG export writes `spreadMethod` over one run of the stops +//! (a conic's wedges and a diamond's quarters repeat with them), and an independent renderer +//! draws the file as the canvas draws the gradient; SVG import reads `spreadMethod` as the +//! gradient's spread; CSS writes repeating gradients. + +use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientSpread, GradientStop, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeKind}; +use vectorcraft_geom::{Point, Rect, Vec2, shapes}; +use vectorcraft_svg::{ExportOptions, export_full, import}; +use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard}; + +/// The SVG rendered by resvg on white at 1 px/pt. +fn resvg_render(svg: &str, w: u32, h: u32) -> Image { + let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse"); + let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap(); + pm.fill(resvg::tiny_skia::Color::WHITE); + resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut()); + Image { width: w, height: h, rgba: pm.data().to_vec() } +} + +/// A red-to-blue gradient of `kind` that spreads by `spread` 3 times, placed at `geom`. +fn paint(kind: GradientKind, spread: GradientSpread, geom: GradientGeom) -> Paint { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient { spread, repeats: 3, ..Gradient::new(kind, stops) }); + g.geom = Some(geom); + Paint::Gradient(Box::new(g)) +} + +/// A 200 × 160 document holding a rectangle painted with `fill` and `stroke`. +fn doc(fill: Paint, stroke: Paint, width: f64) -> Document { + let mut d = Document::new(200.0, 160.0); + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 140.0)), Appearance::basic(fill, stroke, width)); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn cases() -> Vec<(&'static str, GradientKind, GradientGeom)> { + let c = Point::new(100.0, 80.0); + let geom = GradientGeom { start: c, end: c + Vec2::new(50.0, -20.0), aspect: 0.7, focal: None }; + vec![ + ("linear", GradientKind::Linear, GradientGeom { start: Point::new(60.0, 60.0), end: Point::new(150.0, 90.0), aspect: 1.0, focal: None }), + ("radial", GradientKind::Radial, geom), + ("off-center radial", GradientKind::Radial, GradientGeom { focal: Some(c + Vec2::new(-12.0, 10.0)), ..geom }), + ("conic", GradientKind::Conic, geom), + ("diamond", GradientKind::Diamond, geom), + ] +} + +/// Each kind's fill and stroke, repeating and reflecting, exported and drawn by resvg, looks as the +/// canvas paints it, inside the vector and past its ends. +#[test] +fn an_independent_renderer_draws_the_export_as_the_canvas_does() { + for (what, kind, geom) in cases() { + for spread in [GradientSpread::Repeat, GradientSpread::Reflect] { + for stroke in [false, true] { + let d = if stroke { doc(Paint::None, paint(kind, spread, geom), 14.0) } else { doc(paint(kind, spread, geom), Paint::None, 0.0) }; + let o = export_full(&d, &ExportOptions::default(), None); + if matches!(kind, GradientKind::Linear | GradientKind::Radial | GradientKind::Diamond) { + let attr = if spread == GradientSpread::Repeat { "spreadMethod=\"repeat\"" } else { "spreadMethod=\"reflect\"" }; + assert!(o.svg.contains(attr), "{what} {spread:?}: {}", o.svg); + } + // Hard edges (where Repeat starts the stops again, and between a conic's wedges) + // differ by a pixel's antialiasing at most. + assert_similar(&render_artboard(&d), &resvg_render(&o.svg, 200, 160), 10.0, 0.03); + } + } + } +} + +/// One run lies along the vector: a linear gradient's `x2` is a third of the way along it. +#[test] +fn the_written_vector_holds_one_run() { + let geom = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(120.0, 50.0), aspect: 1.0, focal: None }; + let o = export_full(&doc(paint(GradientKind::Linear, GradientSpread::Repeat, geom), Paint::None, 0.0), &ExportOptions::default(), None); + assert!(o.svg.contains("x1=\"30\" y1=\"50\" x2=\"60\" y2=\"50\" spreadMethod=\"repeat\""), "{}", o.svg); +} + +/// On type characters, where SVG writes a conic as a linear gradient along its vector, the runs +/// lie along that vector: a third of it each. +#[test] +fn a_conic_on_characters_repeats_along_its_vector() { + use vectorcraft_doc::{CharStyle, TextObject}; + let geom = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(120.0, 50.0), aspect: 1.0, focal: None }; + let Paint::Gradient(g) = paint(GradientKind::Conic, GradientSpread::Repeat, geom) else { panic!("a gradient") }; + let st = CharStyle { fill: Paint::Gradient(g), size: 48.0, ..CharStyle::default() }; + let mut d = Document::new(200.0, 160.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 80.0), "Fan", st)))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let o = export_full(&d, &ExportOptions::default(), None); + assert!(o.svg.contains("x1=\"30\" y1=\"50\" x2=\"60\" y2=\"50\" spreadMethod=\"repeat\""), "{}", o.svg); +} + +/// `spreadMethod` reads as the gradient's spread, with its stops and vector as written. +#[test] +fn import_reads_spread_method_as_the_spread() { + for (attr, want) in [("repeat", GradientSpread::Repeat), ("reflect", GradientSpread::Reflect), ("pad", GradientSpread::Pad)] { + let svg = format!( + r##""## + ); + let d = import(&svg).unwrap(); + let mut found = None; + d.walk(|n| { + if let (NodeKind::Path { .. }, Paint::Gradient(g)) = (&n.kind, n.appearance.fill_paint()) { + found = Some(g.clone()); + } + }); + let g = found.unwrap_or_else(|| panic!("{attr}: no gradient")); + assert_eq!((g.gradient.spread, g.gradient.repeat_count(), g.gradient.stops.len()), (want, 1, 2), "{attr}"); + let geom = g.geom.unwrap(); + assert_eq!((geom.start, geom.end), (Point::new(40.0, 0.0), Point::new(70.0, 0.0)), "{attr}"); + } +} + +/// Exported and read back, a repeating gradient keeps its spread; its count becomes part of the +/// vector, so it looks the same. +#[test] +fn a_spread_survives_export_and_import() { + let geom = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(150.0, 90.0), aspect: 1.0, focal: None }; + let d = doc(paint(GradientKind::Linear, GradientSpread::Reflect, geom), Paint::None, 0.0); + let o = export_full(&d, &ExportOptions::default(), None); + let back = import(&o.svg).unwrap(); + let mut spreads = vec![]; + back.walk(|n| { + if let Paint::Gradient(g) = n.appearance.fill_paint() { + spreads.push(g.gradient.spread); + } + }); + assert_eq!(spreads, [GradientSpread::Reflect]); + assert_similar(&render_artboard(&d), &render_artboard(&back), 4.0, 0.01); +} diff --git a/crates/tools/src/params.rs b/crates/tools/src/params.rs index 49a5a9a3c1..095e0d21cf 100644 --- a/crates/tools/src/params.rs +++ b/crates/tools/src/params.rs @@ -78,6 +78,14 @@ pub fn gradient_params(g: &GradientPaint) -> Value { if !g.gradient.method.is_classic() { v["method"] = json!(g.gradient.method.label().to_lowercase()); } + // A freeform gradient has no ends to spread past (`paint.setFill` refuses a spread for one). + if g.gradient.is_spread() { + v["spread"] = json!(g.gradient.spread.label().to_lowercase()); + } + // A gradient that pads keeps its count for later. + if g.gradient.repeat_count() != 1 { + v["repeats"] = json!(g.gradient.repeat_count()); + } v } diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index db3aad793e..c6fff7706f 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -47,7 +47,7 @@ pub fn paint_color_at(paint: &Paint, bounds: Option, p: Paint::Solid { color, .. } => Some(*color), Paint::Gradient(g) => { let geom = g.geom.or_else(|| bounds.map(|b| g.resolve(b)))?; - let t = geom.param_at(g.gradient.kind, p); + let t = g.gradient.spread_param(geom.param_at(g.gradient.kind, p)); Some(g.gradient.sample(t.clamp(0.0, 1.0) as f32).0) } _ => None, diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 6c3c76862c..9c8881b580 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -68,6 +68,8 @@ const CENTRE: f64 = 4.5; const CENTRE_HIT: f64 = 8.0; /// A stop dragged farther than this from the bar is deleted on release. const OFF_BAR: f64 = 24.0; +/// Half the length of the ticks across the bar where the runs of a repeating gradient meet. +const TICK: f64 = 5.0; /// A press becomes a drag after this much movement. const DRAG_START: f64 = 3.0; /// Arrow-key nudges of the selected stop (Shift: the big step). @@ -287,6 +289,7 @@ fn annotator_overlays(cx: &ToolContext, a: &Annotator) -> Vec { if len < 1e-12 { return o; } + o.extend(run_ticks(cx, a)); let r = cx.tol(4.0); let (along, across) = (a.vector() / len * r, a.normal() * r); for i in 0..a.gradient.stops.len().saturating_sub(1) { @@ -302,6 +305,31 @@ fn annotator_overlays(cx: &ToolContext, a: &Annotator) -> Vec { o } +/// Where each run of the stops of a gradient that repeats or reflects meets the next: a tick across +/// the bar at each such place along the vector, or, for a conic, a tick across its extent ellipse +/// at each such turn. None for a gradient that pads. +fn run_ticks(cx: &ToolContext, a: &Annotator) -> Vec { + if !a.gradient.is_spread() { + return vec![]; + } + let n = a.gradient.repeat_count(); + let conic = a.gradient.kind == GradientKind::Conic; + let frame = a.geom.conic_frame(); + let tick = a.normal() * cx.tol(TICK); + (1..n) + .flat_map(|k| { + let t = f64::from(k) / f64::from(n); + let (p, q) = if conic { + let (sin, cos) = (t * std::f64::consts::TAU).sin_cos(); + (frame * Point::new(0.9 * cos, 0.9 * sin), frame * Point::new(1.1 * cos, 1.1 * sin)) + } else { + (a.at(t) - tick, a.at(t) + tick) + }; + [Overlay::Line { a: p, b: q, color: LIGHT, dashed: false }, Overlay::Line { a: p, b: q, color: BAR, dashed: true }] + }) + .collect() +} + /// The vector a click applies to `id`: its current gradient's, else the default fit on its box. fn click_vector(cx: &ToolContext, id: NodeId) -> Option { let stroke = !cx.fill_active; diff --git a/crates/tools/src/xform/gradient/tests.rs b/crates/tools/src/xform/gradient/tests.rs index 943ed3af7b..bdfa981c34 100644 --- a/crates/tools/src/xform/gradient/tests.rs +++ b/crates/tools/src/xform/gradient/tests.rs @@ -453,3 +453,36 @@ fn a_radial_with_a_negative_aspect_drags_to_a_positive_one() { let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(100.0, 80.0)))).clone(); assert!((v["aspect"].as_f64().unwrap() - 70.0).abs() < 1e-9, "{v}"); } + +/// A gradient that repeats `n` times shows a tick across the bar where each run meets the next +/// (`n` - 1 of them), and a conic one across its extent at each such turn; one that pads shows +/// none. +#[test] +fn the_annotator_marks_where_the_runs_meet() { + use vectorcraft_color::GradientSpread; + let ticks = |kind: GradientKind, spread: GradientSpread| { + let (mut d, s) = graded(two()); + let n = d.node_mut(s.objects[0]).unwrap(); + let Paint::Gradient(mut g) = n.appearance.fill_paint() else { panic!("a gradient fill") }; + (g.gradient.kind, g.gradient.spread, g.gradient.repeats) = (kind, spread, 4); + let geom = g.geom.unwrap(); + n.appearance.set_fill(Paint::Gradient(g)); + let p = paint(); + let o = GradientTool::default().overlays(&cx(&d, &s, &p)); + let lines: Vec<(Point, Point)> = + o.iter().filter_map(|o| if let Overlay::Line { a, b, dashed: true, .. } = o { Some((*a, *b)) } else { None }).collect(); + (lines, geom) + }; + let (lines, geom) = ticks(GradientKind::Linear, GradientSpread::Repeat); + assert_eq!(lines.len(), 3, "{lines:?}"); + for (k, (a, b)) in lines.iter().enumerate() { + let at = geom.start + (geom.end - geom.start) * ((k + 1) as f64 / 4.0); + assert!(a.midpoint(*b).distance(at) < 1e-9, "tick {k} at {at:?}: {a:?} {b:?}"); + } + let (lines, geom) = ticks(GradientKind::Conic, GradientSpread::Reflect); + assert_eq!(lines.len(), 3); + let frame = geom.conic_frame(); + let quarter = frame * Point::new(0.0, 1.0); + assert!(lines[0].0.midpoint(lines[0].1).distance(quarter) < 1e-9, "{lines:?} {quarter:?}"); + assert!(ticks(GradientKind::Radial, GradientSpread::Pad).0.is_empty()); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e666fcc8c6..7963b1ae2c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1924,6 +1924,8 @@ font-technology Variable Променливи Metallic Gradients Метални разградки Meters Метри Method: Метод: + Pad Удължаване + Repeats Повторения Millimeters Милиметри Minify Минимизиране на код Minimal Минимално diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 2381deb135..5cfd7d9e9e 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1834,6 +1834,8 @@ digits Malayalam Malajalam Save Image Trace Preset Sačuvaj unaprijed postavljeno praćenje slike Delete Image Trace Preset Izbriši unaprijed postavljeno praćenje slike Method: Metoda: + Pad Produži + Repeats Broj ponavljanja Mode: Način: Noise Šum Create: Kreiraj: diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cd6dfad587..ac69dcc544 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2531,6 +2531,8 @@ digits Malayalam Malaiàlam Meters Metres Method Mètode Method: Mètode: + Pad Allarga + Repeats Repeticions Mezzotint Mezzotinta Mezzotint… Mezzotinta… Millimeters Mil·límetres @@ -5094,6 +5096,9 @@ digits Malayalam Malaiàlam @msg `colors` must be an array of colors, not {v} `colors` ha de ser una matriu de colors, no {v} @msg `library` is a library id or name (see {list}) `library` és un identificador o un nom de biblioteca (vegeu {list}) @msg `method` must be classic or perceptual `method` ha de ser classic o perceptual +@msg `spread` must be pad, repeat or reflect `spread` ha de ser pad, repeat o reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` ha de ser un nombre enter d’1 a {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` s’aplica als degradats lineals, radials, cònics i de rombe @msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` i `maxAnchors` han de ser nombres enters @msg `mode` applies to freeform gradients `mode` s’aplica als degradats de forma lliure @msg a negative `aspect` applies to conic gradients un `aspect` negatiu s’aplica als degradats cònics diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 00de3e7057..28b7087088 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1861,6 +1861,8 @@ digits Malayalam Malajálamské Save Image Trace Preset Uložit přednastavení vektorizace obrazu Delete Image Trace Preset Odstranit přednastavení vektorizace obrazu Method: Metoda: + Pad Prodloužit + Repeats Počet opakování Mode: Režim: Noise Šum Create: Vytvořit: @@ -6082,6 +6084,9 @@ axis Y Axis osa Y @msg `dither` applies to gradient fills (paint.setFill) `dither` platí pro výplně přechodem (paint.setFill) @msg `dither` must be true or false `dither` musí být true nebo false @msg `method` must be classic or perceptual `method` musí být classic nebo perceptual +@msg `spread` must be pad, repeat or reflect `spread` musí být pad, repeat nebo reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` musí být celé číslo od 1 do {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` platí pro lineární, kruhové, kuželové a kosočtverečné přechody # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Nainstalovat „{name}“, který žádá o velký výpočetní rozpočet? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Každé spuštění může trvat až {minutes} minut a vykonat {times}krát víc práce, než smějí jiné zásuvné moduly. Běží na pozadí a můžete ho zrušit. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f48e9d6ea3..e07fbe2335 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1692,6 +1692,8 @@ digits Malayalam Malayalam Save Image Trace Preset Gem forudindstilling til billedsporing Delete Image Trace Preset Slet forudindstilling til billedsporing Method: Metode: + Pad Forlæng + Repeats Gentagelser Mode: Tilstand: Noise Støj Create: Opret: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0de0dce0b9..fb41b018b4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2000,6 +2000,8 @@ file-filter Templates Vorlagen Metallic Gradients Metallic-Verläufe Meters Meter Method: Methode: + Pad Fortsetzen + Repeats Wiederholungen Millimeters Millimeter Minify Minifizieren Minimal Minimal @@ -5673,6 +5675,9 @@ axis Y Axis Y-Achse @msg `dither` must be true or false `dither` muss true oder false sein Perceptual Perzeptiv @msg `method` must be classic or perceptual `method` muss classic oder perceptual sein +@msg `spread` must be pad, repeat or reflect `spread` muss pad, repeat oder reflect sein +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` muss eine ganze Zahl von 1 bis {MAX_REPEATS} sein +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` gilt für lineare, radiale, konische Verläufe und Rautenverläufe # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? „{name}“ installieren, das ein großes Rechenbudget verlangt? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Jede Ausführung kann bis zu {minutes} Minuten dauern und {times}-mal so viel Arbeit leisten, wie andere Plug-ins dürfen. Sie läuft im Hintergrund, und Sie können sie abbrechen. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a75edff7a8..43604f6626 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1765,6 +1765,8 @@ font-technology Variable Μεταβλητές Message: Μήνυμα: Metadata: Μεταδεδομένα: Method: Μέθοδος: + Pad Συνέχιση + Repeats Επαναλήψεις Millimeters Χιλιοστά Minimize Ελαχιστοποίηση Mode: Λειτουργία: @@ -6033,6 +6035,9 @@ axis Y Axis Άξονας Y @msg `dither` applies to gradient fills (paint.setFill) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (paint.setFill) @msg `dither` must be true or false Το `dither` πρέπει να είναι true ή false @msg `method` must be classic or perceptual Το `method` πρέπει να είναι classic ή perceptual +@msg `spread` must be pad, repeat or reflect Το `spread` πρέπει να είναι pad, repeat ή reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} Το `repeats` πρέπει να είναι ακέραιος από 1 έως {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients Το `spread` ισχύει για γραμμικά, ακτινικά, κωνικά και ρομβοειδή ντεγκραντέ # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Να εγκατασταθεί το «{name}», που ζητά μεγάλο υπολογιστικό προϋπολογισμό; Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Κάθε εκτέλεση μπορεί να διαρκέσει έως {minutes} λεπτά και να κάνει {times} φορές τη δουλειά που επιτρέπεται στα άλλα πρόσθετα. Εκτελείται στο παρασκήνιο και μπορείτε να την ακυρώσετε. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6d548f6743..3322d57d30 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1994,6 +1994,8 @@ font-technology Variable Variables Metallic Gradients Degradados metálicos Meters Metros Method: Método: + Pad Prolongar + Repeats Repeticiones Millimeters Milímetros Minify Minificar Minimal Mínimo @@ -6024,6 +6026,9 @@ axis Y Axis Eje Y @msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a rellenos de degradado (paint.setFill) @msg `dither` must be true or false `dither` debe ser true o false @msg `method` must be classic or perceptual `method` debe ser classic o perceptual +@msg `spread` must be pad, repeat or reflect `spread` debe ser pad, repeat o reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` debe ser un número entero de 1 a {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` se aplica a los degradados lineales, radiales, cónicos y de rombo # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? ¿Instalar “{name}”, que pide un presupuesto de cálculo grande? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Cada ejecución puede tardar hasta {minutes} minutos y hacer {times} veces el trabajo que se permite a otros plugins. Se ejecuta en segundo plano y se puede cancelar. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7ae54d2bef..a67f087f4c 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1834,6 +1834,8 @@ digits Malayalam Malajalam Save Image Trace Preset Tallenna kuvan jäljityksen esiasetus Delete Image Trace Preset Poista kuvan jäljityksen esiasetus Method: Menetelmä: + Pad Jatka + Repeats Toistokerrat Mode: Tila: Noise Kohina Create: Luo: @@ -6112,6 +6114,9 @@ axis Y Axis Y-akseli @msg `dither` applies to gradient fills (paint.setFill) `dither` koskee liukuväritäyttöjä (paint.setFill) @msg `dither` must be true or false `dither` on oltava true tai false @msg `method` must be classic or perceptual `method` on oltava classic tai perceptual +@msg `spread` must be pad, repeat or reflect `spread` on oltava pad, repeat tai reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` on oltava kokonaisluku väliltä 1–{MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` koskee lineaarisia, säteittäisiä ja kartiomaisia liukuvärejä sekä vinoneliöliukuvärejä # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Asennetaanko ”{name}”, joka pyytää suurta laskentabudjettia? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Jokainen ajo voi kestää enintään {minutes} minuuttia ja tehdä {times} kertaa niin paljon työtä kuin muut laajennukset saavat. Se toimii taustalla, ja voit peruuttaa sen. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 35c649f895..978c980d29 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1988,6 +1988,8 @@ font-technology Variable Variables Metallic Gradients Dégradés métalliques Meters Mètres Method: Méthode : + Pad Prolonger + Repeats Répétitions Millimeters Millimètres Minify Minifier Minimal Minimal @@ -6077,6 +6079,9 @@ axis Y Axis Axe Y @msg `dither` applies to gradient fills (paint.setFill) `dither` s’applique aux fonds en dégradé (paint.setFill) @msg `dither` must be true or false `dither` doit valoir true ou false @msg `method` must be classic or perceptual `method` doit être classic ou perceptual +@msg `spread` must be pad, repeat or reflect `spread` doit être pad, repeat ou reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` doit être un nombre entier de 1 à {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` s’applique aux dégradés linéaires, radiaux, coniques et en losange # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Installer « {name} », qui demande un budget de calcul important ? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Chaque exécution peut durer jusqu’à {minutes} minutes et faire {times} fois le travail permis aux autres modules externes. Elle s’exécute en arrière-plan, et vous pouvez l’annuler. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 335b0c99f0..1a569f37ba 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2762,6 +2762,8 @@ antiAlias Sharp शार्प Meters मीटर Method विधि Method: विधि: + Pad विस्तार करें + Repeats दोहराव Mezzotint मेज़ोटिंट Mezzotint… मेज़ोटिंट… Millimeters मिलीमीटर @@ -5873,6 +5875,9 @@ antiAlias Sharp शार्प @msg `library` is a library id or name (see {list}) `library` एक लाइब्रेरी आईडी या नाम है ( {list} देखें) @msg `line` must be true or false `line` को true या false होना चाहिए @msg `method` must be classic or perceptual `method` क्लासिक या अवधारणात्मक होना चाहिए +@msg `spread` must be pad, repeat or reflect `spread` pad, repeat या reflect होना चाहिए +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 1 से {MAX_REPEATS} तक की पूर्ण संख्या होनी चाहिए +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` रैखिक, रेडियल, शंक्वाकार और डायमंड ग्रेडिएंट्स पर लागू होता है @msg `min` is greater than `max` `min` अधिकतम `max` से अधिक है @msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` और `maxAnchors` पूरे नंबर होना चाहिए @msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°) `maxAngle` ({max_angle}°) से अधिक नहीं हो सकता है। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c3ed22bce8..c783274c23 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2510,6 +2510,8 @@ file-filter Templates Sablonok Meters Méter Method Módszer Method: Módszer: + Pad Folytatás + Repeats Ismétlések Mezzotint Mezzotinto Mezzotint… Mezzotinto… Millimeters Milliméter @@ -6065,6 +6067,9 @@ axis Y Axis Y tengely @msg `dither` applies to gradient fills (paint.setFill) A `dither` színátmenetes kitöltésekre vonatkozik (paint.setFill) @msg `dither` must be true or false `dither`: true vagy false kell @msg `method` must be classic or perceptual a `method` értéke classic vagy perceptual lehet +@msg `spread` must be pad, repeat or reflect a `spread` értéke pad, repeat vagy reflect lehet +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} a `repeats` értéke 1 és {MAX_REPEATS} közötti egész szám lehet +@msg `spread` applies to linear, radial, conic and diamond gradients a `spread` a lineáris, sugaras, kúpos és rombusz színátmenetekre vonatkozik # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Telepíted a(z) „{name}” bővítményt, amely nagy számítási keretet kér? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Egy futás akár {minutes} percig tarthat, és {times}-szor annyi munkát végezhet, mint amennyit más bővítmények. A háttérben fut, és megszakíthatod. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 87c3f55cd5..c483064592 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1692,6 +1692,8 @@ digits Malayalam Malayalam Save Image Trace Preset Simpan preset telusur gambar Delete Image Trace Preset Hapus preset telusur gambar Method: Metode: + Pad Perpanjang + Repeats Jumlah ulangan Mode: Mode: Noise Derau Create: Buat: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a6edc31792..7a01de1bdf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2032,6 +2032,8 @@ file-filter Templates Modelli Metallic Gradients Sfumature metalliche Meters Metri Method: Metodo: + Pad Prolunga + Repeats Ripetizioni Millimeters Millimetri Minify Minifica Minimal Minimale @@ -5707,6 +5709,9 @@ axis Y Axis Asse Y @msg `dither` must be true or false `dither` deve essere true o false Perceptual Percettivo @msg `method` must be classic or perceptual `method` deve essere classic o perceptual +@msg `spread` must be pad, repeat or reflect `spread` deve essere pad, repeat o reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` deve essere un numero intero da 1 a {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` si applica alle sfumature lineari, radiali, coniche e a rombo # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Installare “{name}”, che chiede un budget di calcolo elevato? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Ogni esecuzione può durare fino a {minutes} minuti e svolgere {times} volte il lavoro consentito agli altri plug-in. Viene eseguita in background ed è possibile annullarla. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 65d7721aa1..8d2a51f8c5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2268,6 +2268,8 @@ antiAlias Sharp シャープ Metadata: メタデータ: Meters メートル Method: 方法: + Pad パッド + Repeats リピート回数 Millimeters ミリメートル Minify 最小化 Minimize 最小化 @@ -5772,6 +5774,9 @@ axis Y Axis Y 軸 @msg `dither` must be true or false `dither` には true または false を指定してください Perceptual 知覚的 @msg `method` must be classic or perceptual `method` には classic または perceptual を指定してください +@msg `spread` must be pad, repeat or reflect `spread` には pad、repeat または reflect を指定してください +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` には 1 から {MAX_REPEATS} までの整数を指定してください +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` は線形、円形、円錐およびひし形グラデーションに適用されます # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? 大きな計算予算を求める「{name}」をインストールしますか? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 1 回の実行に最大 {minutes} 分かかり、ほかのプラグインに許される {times} 倍の処理を行うことがあります。バックグラウンドで実行され、キャンセルできます。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index eb686a14a0..db87e7fe61 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1627,6 +1627,8 @@ digits Malayalam მალაიალამური Delete Image Trace Preset გამოსახულების ვექტორიზაციის პრესეტის წაშლა Cancel Image Trace გამოსახულების ვექტორიზაციის გაუქმება Method: მეთოდი: + Pad გაგრძელება + Repeats გამეორებების რაოდენობა Mode: რეჟიმი: Noise ხმაური Create: შექმნა: @@ -6033,6 +6035,9 @@ axis Y Axis Y ღერძი @msg `dither` applies to gradient fills (paint.setFill) `dither` ვრცელდება გრადიენტულ შევსებებზე (paint.setFill) @msg `dither` must be true or false `dither` უნდა იყოს true ან false @msg `method` must be classic or perceptual `method` უნდა იყოს classic ან perceptual +@msg `spread` must be pad, repeat or reflect `spread` უნდა იყოს pad, repeat ან reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` უნდა იყოს მთელი რიცხვი 1-დან {MAX_REPEATS}-მდე +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` ეხება წრფივ, რადიალურ, კონუსურ და რომბულ გრადიენტებს # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? დავაყენოთ „{name}“, რომელიც დიდ გამოთვლით ბიუჯეტს ითხოვს? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. თითოეულ გაშვებას შეიძლება {minutes} წუთამდე დასჭირდეს და სხვა პლაგინებისთვის დაშვებულზე {times}-ჯერ მეტი სამუშაო შეასრულოს. ის ფონურ რეჟიმში მუშაობს და შეგიძლიათ გააუქმოთ. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 52a10ab8e8..e14b18ecd6 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2258,6 +2258,8 @@ antiAlias Sharp 날카롭게 Metadata: 메타데이터: Meters 미터 Method: 방법: + Pad 연장 + Repeats 반복 횟수 Millimeters 밀리미터 Minify 압축 Minimize 최소화 @@ -5668,6 +5670,9 @@ axis Y Axis Y축 @msg `dither` must be true or false `dither`은(는) true 또는 false여야 합니다 Perceptual 지각적 @msg `method` must be classic or perceptual `method`는 classic 또는 perceptual이어야 합니다 +@msg `spread` must be pad, repeat or reflect `spread`는 pad, repeat 또는 reflect여야 합니다 +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats`는 1에서 {MAX_REPEATS} 사이의 정수여야 합니다 +@msg `spread` applies to linear, radial, conic and diamond gradients `spread`는 선형, 방사형, 원뿔형 및 다이아몬드 그레이디언트에 적용됩니다 # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? 큰 계산 예산을 요청하는 “{name}”을(를) 설치하시겠습니까? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 한 번 실행에 최대 {minutes}분이 걸리고 다른 플러그인에 허용되는 작업량의 {times}배를 수행할 수 있습니다. 백그라운드에서 실행되며 취소할 수 있습니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4f9d484155..41b14298fb 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1706,6 +1706,8 @@ digits Malayalam Malayalam Save Image Trace Preset Voorinstelling voor afbeeldingtracering opslaan Delete Image Trace Preset Voorinstelling voor afbeeldingtracering verwijderen Method: Methode: + Pad Doortrekken + Repeats Herhalingen Mode: Modus: Noise Ruis Create: Maken: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e44328480f..c86689a895 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1779,6 +1779,8 @@ digits Malayalam Malajalam Save Image Trace Preset Zapisz ustawienie obrysu obrazu Delete Image Trace Preset Usuń ustawienie obrysu obrazu Method: Metoda: + Pad Przedłuż + Repeats Liczba powtórzeń Mode: Tryb: Noise Szum Create: Utwórz: @@ -5936,6 +5938,9 @@ axis Y Axis Oś Y @msg `dither` applies to gradient fills (paint.setFill) `dither` dotyczy wypełnień gradientem (paint.setFill) @msg `dither` must be true or false `dither` musi mieć wartość true lub false @msg `method` must be classic or perceptual `method` musi mieć wartość classic lub perceptual +@msg `spread` must be pad, repeat or reflect `spread` musi mieć wartość pad, repeat lub reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` musi być liczbą całkowitą od 1 do {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` dotyczy gradientów liniowych, radialnych, stożkowych i rombowych # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Zainstalować „{name}”, wtyczkę, która prosi o duży budżet obliczeń? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Każde uruchomienie może trwać do {minutes} minut i wykonać {times} razy więcej pracy, niż mogą inne wtyczki. Działa w tle i można je anulować. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3ce111f7a4..81eac6497f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1701,6 +1701,8 @@ antiAlias Sharp Nítido Metallic Gradients Gradientes Metálicos Meters Metros Method: Método: + Pad Prolongar + Repeats Repetições Millimeters Milímetros Minify Minificar Minimize Minimizar @@ -5997,6 +5999,9 @@ axis Y Axis Eixo Y @msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a preenchimentos em gradiente (paint.setFill) @msg `dither` must be true or false `dither` deve ser true ou false @msg `method` must be classic or perceptual `method` deve ser classic ou perceptual +@msg `spread` must be pad, repeat or reflect `spread` deve ser pad, repeat ou reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` deve ser um número inteiro de 1 a {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` se aplica a gradientes lineares, radiais, cônicos e em losango # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Instalar “{name}”, que pede um orçamento de processamento grande? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Cada execução pode levar até {minutes} minutos e fazer {times} vezes o trabalho permitido a outros plug-ins. Ela é executada em segundo plano, e você pode cancelá-la. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c93807c08a..dbbe7722b5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2021,6 +2021,8 @@ file-filter Templates Шаблоны Metallic Gradients Металлические градиенты Meters Метры Method: Метод: + Pad Продлить + Repeats Число повторов Millimeters Миллиметры Minify Минифицировать Minimal Минимальный @@ -5693,6 +5695,9 @@ axis Y Axis Ось Y @msg `dither` must be true or false `dither` должно быть true или false Perceptual Перцепционный @msg `method` must be classic or perceptual `method` должен быть classic или perceptual +@msg `spread` must be pad, repeat or reflect `spread` должен быть pad, repeat или reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` должен быть целым числом от 1 до {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` применяется к линейным, радиальным, коническим и ромбовидным градиентам # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Установить «{name}», которому нужен большой вычислительный бюджет? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Каждый запуск может длиться до {minutes} минут и выполнять в {times} раз больше работы, чем разрешено другим модулям. Он работает в фоне, и его можно отменить. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ce20b6c72d..306270c048 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1935,6 +1935,8 @@ font-technology Variable Variabla Metallic Gradients Metalliska gradienter Meters Meter Method: Metod: + Pad Förläng + Repeats Upprepningar Millimeters Millimeter Minify Minifiera Minimal Minimal @@ -6104,6 +6106,9 @@ axis Y Axis Y-axel @msg `dither` applies to gradient fills (paint.setFill) `dither` gäller gradientfyllningar (paint.setFill) @msg `dither` must be true or false `dither` måste vara true eller false @msg `method` must be classic or perceptual `method` måste vara classic eller perceptual +@msg `spread` must be pad, repeat or reflect `spread` måste vara pad, repeat eller reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` måste vara ett heltal från 1 till {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` gäller för linjära, radiella och koniska gradienter och rombgradienter # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Installera ”{name}”, som begär en stor beräkningsbudget? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Varje körning kan ta upp till {minutes} minuter och göra {times} gånger så mycket arbete som andra plugins får. Den körs i bakgrunden och du kan avbryta den. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index db3c4501b7..ff7f70b56f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1828,6 +1828,8 @@ font-technology Variable Değişken Metallic Gradients Metalik Degradeler Meters Metre Method: Yöntem: + Pad Uzat + Repeats Tekrar sayısı Millimeters Milimetre Minify Küçült Minimal Asgari @@ -6038,6 +6040,9 @@ axis Y Axis Y ekseni Geometric Patterns Geometrik Desenler Grit Textures Grenli Dokular @msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır +@msg `spread` must be pad, repeat or reflect `spread` pad, repeat veya reflect olmalıdır +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 1 ile {MAX_REPEATS} arasında bir tam sayı olmalıdır +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` doğrusal, radyal, konik ve elmas degradeler için geçerlidir # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Büyük bir hesaplama bütçesi isteyen “{name}” yüklensin mi? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Her çalıştırma {minutes} dakikaya kadar sürebilir ve diğer eklentilere izin verilen işin {times} katını yapabilir. Arka planda çalışır ve iptal edebilirsiniz. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 151a886cf9..c673605b21 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2026,6 +2026,8 @@ file-filter Templates Шаблони Metallic Gradients Металеві градієнти Meters Метри Method: Метод: + Pad Продовжити + Repeats Кількість повторів Millimeters Міліметри Minify Мінімізувати Minimal Мінімальний @@ -5687,6 +5689,9 @@ axis Y Axis Вісь Y @msg `dither` must be true or false `dither` має бути true або false Perceptual Перцептивний @msg `method` must be classic or perceptual `method` має бути classic або perceptual +@msg `spread` must be pad, repeat or reflect `spread` має бути pad, repeat або reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` має бути цілим числом від 1 до {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` застосовується до лінійних, радіальних, конічних і ромбоподібних градієнтів # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Встановити «{name}», якому потрібен великий обчислювальний бюджет? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Кожен запуск може тривати до {minutes} хвилин і виконувати в {times} разів більше роботи, ніж дозволено іншим модулям. Він працює у фоні, і його можна скасувати. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f3b505c648..b5816743bd 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2334,6 +2334,8 @@ digits Hindi Hindi Meters Mét Method Phương pháp Method: Phương pháp: + Pad Kéo dài + Repeats Số lần lặp Mezzotint Mezzotint Mezzotint… Mezzotint… Millimeters Milimét @@ -5688,6 +5690,9 @@ axis Y Axis Trục Y @msg `dither` must be true or false `dither` phải là true hoặc false Perceptual Theo cảm nhận @msg `method` must be classic or perceptual `method` phải là classic hoặc perceptual +@msg `spread` must be pad, repeat or reflect `spread` phải là pad, repeat hoặc reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` phải là số nguyên từ 1 đến {MAX_REPEATS} +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` áp dụng cho chuyển sắc tuyến tính, tỏa tròn, hình nón và hình thoi # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Cài đặt “{name}”, plug-in yêu cầu ngân sách tính toán lớn? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Mỗi lần chạy có thể mất tới {minutes} phút và làm khối lượng việc gấp {times} lần mức cho phép với các plug-in khác. Plug-in chạy trong nền và bạn có thể hủy. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c7219b9448..5cc3853bbb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1903,6 +1903,8 @@ file-filter Templates 模板 Metallic Gradients 金属渐变 Meters 米 Method: 方法: + Pad 延伸 + Repeats 重复次数 Millimeters 毫米 Minify 压缩 Minimal 最少 @@ -5920,6 +5922,9 @@ axis Y Axis Y 轴 @msg `dither` applies to gradient fills (paint.setFill) `dither` 适用于渐变填色 (paint.setFill) @msg `dither` must be true or false `dither` 必须是 true 或 false @msg `method` must be classic or perceptual `method` 必须是 classic 或 perceptual +@msg `spread` must be pad, repeat or reflect `spread` 必须是 pad、repeat 或 reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 必须是 1 到 {MAX_REPEATS} 之间的整数 +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` 适用于线性、径向、圆锥和菱形渐变 # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? 要安装需要大量计算预算的“{name}”吗? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 每次运行最多可能需要 {minutes} 分钟,并可完成其他插件所允许工作量的 {times} 倍。它在后台运行,可以随时取消。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1c753576a5..44c86f9955 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2978,6 +2978,8 @@ file-filter Templates 範本 Metallic Gradients 金屬漸層 Meters 公尺 Method: 方法: + Pad 延伸 + Repeats 重複次數 Millimeters 公釐 Minify 縮減檔案大小 Minimal 最少 @@ -5725,6 +5727,9 @@ axis Y Axis Y 軸 @msg `dither` must be true or false `dither` 必須是 true 或 false Perceptual 感知 @msg `method` must be classic or perceptual `method` 必須是 classic 或 perceptual +@msg `spread` must be pad, repeat or reflect `spread` 必須是 pad、repeat 或 reflect +@msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` 必須是 1 到 {MAX_REPEATS} 之間的整數 +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` 適用於線性、放射狀、圓錐和菱形漸層 # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? 要安裝需要大量運算預算的「{name}」嗎? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 每次執行最多可能需要 {minutes} 分鐘,並可完成其他增效模組所允許工作量的 {times} 倍。它會在背景執行,您可以隨時取消。 diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 500649b7ed..83f003e150 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -28,9 +28,9 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; use vectorcraft_color::gradient::{ - MIN_STOPS, duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint, swap_stop_colors, + MAX_REPEATS, MIN_STOPS, duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint, swap_stop_colors, }; -use vectorcraft_color::{Color, Freeform, FreeformMode, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint}; +use vectorcraft_color::{Color, Freeform, FreeformMode, Gradient, GradientKind, GradientMethod, GradientPaint, GradientSpread, GradientStop, Paint}; use vectorcraft_doc::{NodeId, StrokeGradientMode}; use super::{active_paint, live_run, pstate, set_pstate}; @@ -315,6 +315,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { return; } method_row(app, ui, &g, is_grad); + spread_row(app, ui, &g, is_grad); // The proxy beside the angle and aspect ratio fields. ui.horizontal(|ui| { super::proxy(app, ui, 36.0); @@ -397,6 +398,39 @@ fn method_row(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: }); } +/// The Spread row: what a linear, radial, conic or diamond gradient paints past the ends of its +/// stops (Pad, Repeat or Reflect), and how many times Repeat and Reflect run the stops along the +/// vector (1 to [`MAX_REPEATS`]; dimmed with Pad). +fn spread_row(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) { + let spreads = [(GradientSpread::Pad, tl!("Pad")), (GradientSpread::Repeat, tl!("Repeat")), (GradientSpread::Reflect, tl!("Reflect"))]; + let current = g.gradient.spread; + let shown = spreads.iter().find(|(s, _)| *s == current).map_or("", |(_, l)| *l); + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Spread:")); + let labels = spreads.map(|(_, l)| l); + let chosen = ui.add_enabled_ui(is_grad, |ui| widgets::dropdown_names(ui, "grad-spread", shown, &labels, 80.0)).inner; + if let Some((s, _)) = chosen.and_then(|i| spreads.get(i)) + && *s != current + { + edit(app, json!({"spread": s.label().to_lowercase()}), Live::Released); + } + let on = is_grad && !current.is_pad(); + let n = f64::from(g.gradient.repeat_count()); + let set = ui + .add_enabled_ui(on, |ui| widgets::spin_plain_named(ui, "grad-repeats", Some(tl!("Repeats")), n, "", 0, 56.0, 1.0, 1.0, &REPEAT_PRESETS)) + .inner; + if let Some(r) = set { + let r = r.round().clamp(1.0, f64::from(MAX_REPEATS)); + if r != n { + edit(app, json!({"repeats": r}), Live::Released); + } + } + }); +} + +/// The Repeats field's menu. +const REPEAT_PRESETS: [f64; 6] = [1.0, 2.0, 3.0, 4.0, 5.0, 10.0]; + /// The Presets row: a menu of the preset gradients by library (theme), then the /// saved ones; a choice applies to the gradient behind the active proxy, keeping its placement /// (`gradient.presets.apply`; a solid or no paint becomes the gradient). The menu shows the preset diff --git a/crates/ui-egui/src/panels/gradient/tests.rs b/crates/ui-egui/src/panels/gradient/tests.rs index 27b0eda0b4..589dbc523a 100644 --- a/crates/ui-egui/src/panels/gradient/tests.rs +++ b/crates/ui-egui/src/panels/gradient/tests.rs @@ -328,6 +328,44 @@ fn the_method_menu_makes_the_gradient_perceptual() { assert_eq!(fill(&app).gradient.method, GradientMethod::Classic); } +/// The Spread menu makes the gradient repeat (one undo step), and the Repeats field, dimmed while +/// it pads, then sets the count. +#[test] +fn the_spread_menu_and_repeats_field_set_the_spread() { + use vectorcraft_color::GradientSpread; + let mut app = app(json!({"gradient": {}})); + let mut p = Panel::new(); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + assert!(texts.iter().any(|(t, _)| t == "Spread:"), "{texts:?}"); + let shown = texts.iter().find(|(t, _)| t == "Pad").expect("Pad shown").1; + let undo = app.session.doc().unwrap().history.undo.len(); + let key = |k, modifiers| Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }; + // While it pads, the Repeats field takes no count. + let field = p.widget_after(shown); + p.click(&mut app, field.center()); + p.frame(&mut app, vec![key(egui::Key::A, Modifiers::COMMAND), Event::Text("4".into())], Modifiers::NONE); + p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE); + assert_eq!((fill(&app).gradient.repeats, app.session.doc().unwrap().history.undo.len()), (1, undo), "dimmed with Pad"); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + let shown = texts.iter().find(|(t, _)| t == "Pad").expect("Pad shown").1; + let texts = p.click(&mut app, shown); + let option = texts.iter().find(|(t, _)| t == "Reflect").expect("the menu lists Reflect").1; + p.click(&mut app, option); + assert_eq!(fill(&app).gradient.spread, GradientSpread::Reflect); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // The count: typed into the field beside the menu. + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + let shown = texts.iter().find(|(t, _)| t == "Reflect").expect("the menu shows Reflect").1; + let field = p.widget_after(shown); + p.click(&mut app, field.center()); + p.frame(&mut app, vec![key(egui::Key::A, Modifiers::COMMAND), Event::Text("4".into())], Modifiers::NONE); + p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE); + assert_eq!(fill(&app).gradient.repeat_count(), 4); + app.run("edit.undo", json!({})).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(fill(&app).gradient.spread, GradientSpread::Pad); +} + #[test] fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() { let stops = json!([{"offset": 0, "color": "#ffffff", "midpoint": 0.3}, {"offset": 1, "color": "#000000"}]); diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs index 017ba588ce..87c6034bda 100644 --- a/crates/ui-egui/src/widgets/chips.rs +++ b/crates/ui-egui/src/widgets/chips.rs @@ -111,7 +111,7 @@ pub(super) fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: } (0..n) .map(|k| { - let t = geom.param_at(gp.gradient.kind, at(k)).clamp(0.0, 1.0) as f32; + let t = gp.gradient.spread_param(geom.param_at(gp.gradient.kind, at(k))).clamp(0.0, 1.0) as f32; rgba(gp.gradient.sample(t)) }) .collect() diff --git a/docs/gaps.md b/docs/gaps.md index 83afa40942..feedfa8839 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -414,9 +414,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** COLR colour fonts (v0 and v1): a glyph's layers are vector art (solid colours, linear and radial gradients, clips, blend modes, palette 0, the foreground colour from the run's fill), placed with the type, drawn on the canvas, written as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as text in PDF, and editable - paths with Create Outlines. Limits: glyphs using sweep gradients, Porter-Duff compositing, radial gradients from a - circle that isn't the centre, or repeating gradients are images of the glyph until documents have those paints; - estimate unchanged. + paths with Create Outlines. Limits: glyphs using sweep gradients, Porter-Duff compositing or radial gradients from a + circle that isn't the centre are images of the glyph until documents have those paints, and glyphs using repeating + or reflecting gradients are images until the color font reader maps them to a gradient's spread; estimate unchanged. - **Done:** OpenType SVG colour fonts: each glyph's SVG document is read by the SVG importer into vector art, in the run's colours where it uses `currentColor` (or the legacy `context-fill`/`context-stroke`), then drawn, exported and outlined as COLR glyphs are (PDF real text writes them as text that draws nothing, their art drawn where they sit). Images in glyph documents diff --git a/docs/mcp.md b/docs/mcp.md index baa2631f7b..b9d903e76e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -794,7 +794,7 @@ symbol (named after its `data-name`, else its id) with an instance per ``; symbol with strokes) stays art. `feGaussianBlur`, `feDropShadow` and the usual shadow chains (offset, blur, flood or colour matrix, composite, merge) become Gaussian Blur, Drop Shadow, Outer Glow, Inner Glow and Feather effects; other filters are listed in `warnings`. Undisplayed objects (`display:none`) come back hidden, nested clip paths intersect, -and reflected or repeated gradients (`spreadMethod`) are expanded into stops. `` files (relative links are +and reflected or repeated gradients (`spreadMethod`) import as gradients that repeat or reflect (see Spread under Examples). `` files (relative links are found in the SVG's folder; only files on this computer or the SVG's network share are read, and pasted SVG reads only absolute paths) stay linked images (see Linked images); SVG files and `data:` SVGs become art; a file that can't be read becomes a placeholder in its box, named in `warnings` and `missingLinks`. @@ -1143,6 +1143,41 @@ another form gives a warning: {"name":"run_command","arguments":{"command":"paint.setFill","params":{"gradient":{"kind":"diamond","start":[150,150],"end":[200,150],"aspect":60}}}} ``` +Spread (the Gradient panel's menu, beside a Repeats field) says what a linear, radial, conic or diamond gradient +paints past the ends of its stops. `pad` (the default) carries the first and last colors on. `repeat` and `reflect` +run the stops `repeats` times along the vector (1 to 100, VectorCraft's own limit; default 1) and keep going past +both ends of it, `repeat` starting the stops again each time and `reflect` running every other run backwards. A +radial or diamond gradient repeats in rings or squares out from its center (an off-center radial's rings grow about +its focal point), and a conic one repeats around the turn. A freeform gradient has no ends, so a `spread` other +than `pad` is an error for one, and so is a `repeats` that is not a whole number from 1 to 100. Both edit as one +undo step, and an edit that changes nothing records none. A gradient set back to `pad` keeps its count for later, +and it matches a preset or swatch of the same gradient whatever count it keeps. The Gradient tool marks each place +along the bar where one run meets the next (on a conic, across its extent ellipse). A gradient along or across a +stroke runs its stops `repeats` times along or across it, a blend between two gradients that repeat (or reflect) +steps the count from one to the other, and a gradient swatch saved as CSS gets its stops repeated across the box. The +Gradient tool's stop and midpoint markers lie along the whole bar, as on the Gradient panel's slider, and edit the +stops of one run. + +SVG export writes `spreadMethod` with the gradient's vector shortened to one run +(https://www.w3.org/TR/SVG2/pservers.html), and SVG import reads +`spreadMethod` as the spread, with a count of 1. CSS writes `repeating-linear-gradient` and `repeating-radial-gradient` over one run, or +one run and its mirror for `reflect` (https://www.w3.org/TR/css-images-3/#repeating-gradients), and a conic gradient +with its stops repeated around the turn. PDF writes a conic gradient's sweep shading over one run, repeated or +reflected past it. PDF, EPS and legacy `.ai` gradients pad past their ends, so a linear, radial or diamond gradient +is written with its stops repeated as many times as the shape needs: at most 256 runs and 16,384 stops (256 stops in +legacy `.ai`), and for a radial or diamond gradient no farther from its center than 65,536 runs, or than a million +times the smallest gap between its stops (measured in runs) when that is less. Past those it pads, with a warning. +The `.ai` editing data has no spread either: it holds the stops repeated across the shape, at most 256 of them, with +a warning. PDF import finds a sharp step between two colors in an axial or radial shading, in any PDF, by halving +the spans between its samples, so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis, with at +most 16,384 samples a shading) instead of blurred across the samples around it. Object › Expand expands the +repeated stops, into the same whole number of `steps` objects for each run (at least one, so about `steps` in all), +each run starting on an object's edge: + +```json +{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"spread":"reflect","repeats":3}}} +``` + Dither (the Gradient panel's box) draws a linear, radial, conic or diamond gradient fill with fine noise that hides banding; SVG and PDF exports write such a fill as an image at the Document Raster Effects resolution. It applies to fills (`stroke: true` is refused): diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f28078ec33..7b7e0e1315 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1337,6 +1337,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Gradient panel's Delete Stop sits at the right end of the ramp, level with it, so a panel too short to show the stop fields still shows it; it deletes the selected stop (never below two), as dragging a stop down off the ramp does (#5403). - Conic gradients, VectorCraft's own gradient type: the stops sweep once around the center from the vector, clockwise on the page (a negative aspect, or a reflection, turns the sweep the other way), with a Conic button in the Gradient panel's Type row and the Gradient tool's extent ellipse and aspect handle; they follow transforms, Expand into wedges or a mesh, open from `.af` files, and export to PDF as a function-based shading that PDF import reads back as a conic gradient; SVG, EPS and legacy `.ai` files get solid wedges, and CSS a `conic-gradient`. - Diamond gradients, VectorCraft's own gradient type: the stops run from the center out to a square with a corner at the end of the vector (the aspect stretches it into a rhombus), with a Diamond button in the Gradient panel's Type row and the Gradient tool's square and aspect handle; they follow transforms, Expand into nested squares or a mesh, and export to PDF, SVG, EPS and legacy `.ai` as four linear gradients, one for each quarter of the square (an image in PDF and SVG for translucent stops, a dithered fill, the Perceptual method other than spot color shadings in PDF, or a square with no area), and on type characters in SVG as a radial gradient; PDF import reads a function-based shading whose color is the same along each square about its origin as a diamond gradient. + - Gradient spread, VectorCraft's own setting: Pad, Repeat or Reflect, with a Repeats count of 1 to 100, in the Gradient panel and on `paint.editGradient` and `paint.setFill`; Repeat and Reflect run a linear, radial, conic or diamond gradient's stops that many times along the vector and on past its ends, the Gradient tool marks where the runs meet, and SVG writes and reads `spreadMethod`, CSS writes repeating gradients, PDF writes a conic's sweep shading over one run and the other kinds with their stops repeated across the shape, as EPS, legacy `.ai` and the `.ai` editing data do. - The stop popover a double-click on a stop opens (the Gradient tool's annotator or the Gradient panel) has an eyedropper beside its Color and Swatches tabs: it closes the popover and samples the next colour clicked on the art into the stop, as the Gradient panel's eyedropper does (#4681). Cmd/Ctrl-click on the Gradient panel's Gradient Fill box or on the Tools panel's Gradient button resets the gradient of the active Fill or Stroke to the linear White, Black gradient, as one undo step ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. From 3bcdbd7e712813202137d214c21f1cbc1177e966 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:54:44 -0800 Subject: [PATCH 67/79] Type: letter spacing and Full Justify keep shaped clusters together Single Word Justification's Full Justify counted and placed a gap after every drawn glyph, so a combining mark or the vowel sign of a Devanagari syllable moved away from its letter. Desired Letter Spacing and the letter spacing range of a justified line did the same between two glyphs of one cluster. - The Full Justify gap count and placement share one check that skips glyphs with the same cluster byte as the glyph drawn after them. - Letter spacing goes after the last glyph of a cluster only. - Letter spacing, Full Justify and CJK inter-character gaps pass over every default-ignorable character drawn with no width (soft hyphens, bidi marks, embeddings, overrides and isolates, joiners, word joiners, variation selectors, tags, the byte order mark) and put the space between the visible characters on either side. The Hangul fillers stay letters. - A zero-width space takes no word-space room on a justified line and stays a line break opportunity. Letter spacing before it is set after it, so a line broken there ends at its last letter. - A single word that Full Justify spreads and that breaks at a hyphen has the same gap before the drawn hyphen as between its letters. - Kinsoku push-in counts the gaps it can close past format controls, as the layout closes them. Tests cover horizontal, right-to-left and vertical type, Desired and fitted letter spacing, format controls, zero-width spaces, the drawn hyphen, push-in, and a Devanagari syllable when a Devanagari face is installed. --- crates/text/src/justify.rs | 84 +++++++- crates/text/src/layout/breaking.rs | 11 +- crates/text/src/layout/flow.rs | 70 ++++-- crates/text/src/layout/wrapping_tests.rs | 15 ++ crates/text/src/tests/devanagari.rs | 2 +- crates/text/src/tests/justify.rs | 257 +++++++++++++++++++++++ docs/target-app-parity.md | 1 + 7 files changed, 414 insertions(+), 26 deletions(-) diff --git a/crates/text/src/justify.rs b/crates/text/src/justify.rs index 53de928be2..3a4f561fb7 100644 --- a/crates/text/src/justify.rs +++ b/crates/text/src/justify.rs @@ -48,14 +48,61 @@ impl Flex { } } -/// A glyph justification spreads space over as a word space (tabs keep their stops). +/// A glyph justification spreads space over as a word space (tabs keep their stops). The +/// zero-width space is a line break opportunity but not a word separator, so it takes none. pub(crate) fn is_word_space(g: &SGlyph) -> bool { - g.is_space() && g.ch != '\t' + g.is_space() && g.ch != '\t' && !is_format_control(g.ch) } -/// A glyph letter spacing follows: a visible glyph followed by another one in the same word. +/// A zero-width format control: an assigned default-ignorable character (Unicode's +/// Default_Ignorable_Code_Point) that is drawn with no width. That is a soft hyphen, a bidi mark, +/// embedding, override or isolate (the Arabic letter mark included), a zero-width space, +/// non-joiner or joiner, a word joiner or invisible operator, a combining grapheme joiner, a +/// variation selector, a Mongolian free variation selector or vowel separator, a shorthand or +/// musical format control, a tag, or the byte order mark. Letter spacing and justification add no +/// space next to one; the space goes between the visible characters on either side. The +/// zero-width space stays a line break opportunity ([`SGlyph::is_space`]), and it takes the letter +/// spacing of the letter before it, which a line that breaks there trims. The Hangul fillers, +/// which fonts draw with a width, are letters. +pub(crate) fn is_format_control(ch: char) -> bool { + matches!( + ch, + '\u{00AD}' + | '\u{034F}' + | '\u{061C}' + | '\u{17B4}'..='\u{17B5}' + | '\u{180B}'..='\u{180F}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{206F}' + | '\u{FE00}'..='\u{FE0F}' + | '\u{FEFF}' + | '\u{1BCA0}'..='\u{1BCA3}' + | '\u{1D173}'..='\u{1D17A}' + | '\u{E0000}'..='\u{E0FFF}' + ) +} + +/// The zero-width space: a format control that is a line break opportunity. +const ZERO_WIDTH_SPACE: char = '\u{200B}'; + +/// A glyph that may take the letter spacing before the next character: a visible glyph that is +/// not a space, a line break, an inline graphic or a format control. +fn gap_owner(g: &SGlyph) -> bool { + !g.is_space() && g.ch != '\u{2028}' && g.inline.is_none() && !is_format_control(g.ch) +} + +/// Whether `next`, the glyph after `g` (passing over format controls other than the zero-width +/// space), continues the word: a visible character of another shaped cluster. +fn continues_word(g: &SGlyph, next: Option<&SGlyph>) -> bool { + next.is_some_and(|n| n.byte != g.byte && !n.is_space() && n.ch != '\u{2028}') +} + +/// A glyph letter spacing follows: a visible glyph followed by another character in the same word +/// (`next` is the next glyph that is not a format control, or a zero-width space). The glyphs of +/// one shaped cluster (a letter and its combining marks) take none between them. fn letter_gap(g: &SGlyph, next: Option<&SGlyph>) -> bool { - !g.is_space() && g.ch != '\u{2028}' && g.inline.is_none() && next.is_some_and(|n| !n.is_space() && n.ch != '\u{2028}') + gap_owner(g) && continues_word(g, next) } /// The width of a word space in `g`'s font and size: what letter spacing is a share of. @@ -73,9 +120,33 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g let [l_min, l_des, l_max] = j.letter_spacing.map(pct); let [g_min, g_des, g_max] = if scale_glyphs { j.glyph_scaling.map(pct) } else { [1.0; 3] }; let mut flex = vec![Flex::default(); sg.len()]; + // The last glyph before `k` that is not a format control, when that glyph may take a letter gap. + let mut owner: Option = None; for k in 0..sg.len() { - let gap = letter_gap(&sg[k], sg.get(k + 1)); + let ch = sg[k].ch; + let zero_width_space = ch == ZERO_WIDTH_SPACE; + // Only a glyph that is not a format control, or a zero-width space, looks past the + // controls after it, up to the next zero-width space, so each run of controls is passed + // over once. + let scans = zero_width_space || !is_format_control(ch); + let next = + if scans { sg.get(k + 1..).and_then(|rest| rest.iter().find(|n| !is_format_control(n.ch) || n.ch == ZERO_WIDTH_SPACE)) } else { None }; + // A letter followed by a zero-width space takes no gap. The zero-width space takes the + // letter's gap instead (the letter's space width, after its glyph scaling), so a line + // that breaks there ends without letter spacing. + let space_gap = if zero_width_space && continues_word(&sg[k], next) { owner.and_then(|o| sg.get(o)).map(space_width) } else { None }; + let gap = !zero_width_space && scans && letter_gap(&sg[k], next); + if !is_format_control(ch) { + owner = None; + } let (Some(g), Some(f)) = (sg.get_mut(k), flex.get_mut(k)) else { continue }; + if zero_width_space { + if let Some(space) = space_gap { + g.adv += space * l_des; + f.letter = (space * (l_max - l_des).max(0.0), space * (l_des - l_min).max(0.0)); + } + continue; + } if is_word_space(g) { // The font's word space (with its tracking), set at Desired. let base = g.adv; @@ -102,6 +173,9 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g g.adv += space * l_des; f.letter = (space * (l_max - l_des).max(0.0), space * (l_des - l_min).max(0.0)); } + if gap_owner(g) { + owner = Some(k); + } } flex } diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 0f033d4fcb..0ac57e0833 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -4,6 +4,7 @@ use super::{ Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, KinsokuShoriType, LeadingModel, SGlyph, hyphen_glyph, is_cjk, no_line_end, no_line_start, points_for, soft_line_start, }; +use crate::justify::is_format_control; /// Vertical metrics of a line: (ascent, descent, leading, cap height, x height). #[derive(Clone, Copy, Debug, PartialEq)] @@ -659,7 +660,7 @@ fn push_in( || (a + 2 == last && punct(last - 1)) || (a + 3 == last && punct(last - 2) && quote(last - 1))) }; - let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum(); + let room: f64 = (i..end - 1).filter(|&a| closes(g, a, end) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum(); if x - line_end_credit(g, line_end, last) - width > room + EPS { return None; } @@ -685,9 +686,11 @@ fn push_in( /// May a push-in close the gap after glyph `a`? A gap between two characters, at least one of them /// CJK, outside a tate-chu-yoko block, a list marker and a shaped cluster, and not next to a space, -/// a tab or an inline graphic before it. -fn closes(g: &[SGlyph], a: usize) -> bool { - let (Some(p), Some(n)) = (g.get(a), g.get(a + 1)) else { return false }; +/// a tab or an inline graphic before it. Zero-width format controls take no gap: the gap is the +/// one between the visible characters on either side, before `end`, as the layout justifies it. +fn closes(g: &[SGlyph], a: usize, end: usize) -> bool { + let Some(p) = g.get(a).filter(|p| !is_format_control(p.ch)) else { return false }; + let Some(n) = g.get(a + 1..end).and_then(|rest| rest.iter().find(|n| !is_format_control(n.ch))) else { return false }; p.len > 0 && p.byte != n.byte && p.inline.is_none() diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index d7ac206424..79b8f4326b 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -10,6 +10,7 @@ use super::{ ListMarker, Mojikumi, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics, }; +use crate::justify::is_format_control; use crate::shape::keep_korean_words; /// Does paragraph `para` push characters in (Kinsoku Shori Type Push In First or Push Out First, @@ -707,14 +708,56 @@ fn drawn_after(index: usize, glyph: usize, after: &[Option]) -> Option], sg: &[SGlyph]) -> Option { + let mut next = drawn_after(index, glyph, after); + for _ in 0..after.len() { + match next { + Some(n) if sg.get(n).is_some_and(|g| is_format_control(g.ch)) => next = drawn_after(index, n, after), + _ => break, + } + } + next +} + +// The glyph after which a gap between drawn glyphs opens, and the visible glyph that follows it: +// not a held glyph, and never a zero-width format control on either side. +fn gap_pair<'a>(glyph: usize, held: &[usize], after: &[Option], index: usize, sg: &'a [SGlyph]) -> Option<(&'a SGlyph, &'a SGlyph)> { + if held.contains(&glyph) { + return None; + } + let a = sg.get(glyph).filter(|a| !is_format_control(a.ch))?; + visible_after(index, glyph, after, sg).and_then(|next| sg.get(next)).map(|b| (a, b)) +} + // Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the // gaps next to a CJK character, not inside a Latin word, a tate-chu-yoko block or a shaped // cluster. fn is_cjk_gap(glyph: usize, held: &[usize], after: &[Option], index: usize, sg: &[SGlyph]) -> bool { - !held.contains(&glyph) - && drawn_after(index, glyph, after).and_then(|next| sg.get(glyph).zip(sg.get(next))).is_some_and(|(a, b)| { - a.byte != b.byte && !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)) - }) + gap_pair(glyph, held, after, index, sg).is_some_and(|(a, b)| { + a.byte != b.byte && !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)) + }) +} + +// Single Word Justification's Full Justify spreads a line's leftover room over the gaps between +// its drawn glyphs: not after a held glyph, nor inside a tate-chu-yoko block (one cell) or a +// shaped cluster, whose glyphs (a letter and its combining marks, a conjunct and its matra) stay +// together. On a line that ends in a drawn hyphen, the gap before the hyphen counts too: it follows +// the line's last visible glyph. +fn is_letter_gap(glyph: usize, held: &[usize], after: &[Option], index: usize, sg: &[SGlyph], hyphen: bool) -> bool { + if held.contains(&glyph) || sg.get(glyph).is_none_or(|a| is_format_control(a.ch)) { + return false; + } + match gap_pair(glyph, held, after, index, sg) { + Some((a, b)) => a.byte != b.byte && !b.continues_tcy(), + None => hyphen, + } +} + +// A space that takes a share of a justified line's room: not a zero-width space, which is a line +// break opportunity but not a word separator. +fn is_justified_space(g: &SGlyph) -> bool { + g.is_space() && !is_format_control(g.ch) } // May a pushed-in line close a CJK gap after `g`? Not after a list marker or an inline graphic, @@ -760,7 +803,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin Justify::JustifyAll => (0, true), }; justify &= paragraph.regions.is_some(); - let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && sg[j].is_space()).count(); + let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && is_justified_space(&sg[j])).count(); // A gap that justification adds between two glyphs goes into the advance of the one // drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a // right-to-left line comes before it in the text. @@ -812,7 +855,8 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin // Word spacing, letter spacing and glyph scaling within their ranges, then the // word spaces alone; composed lines may shrink word spaces (never below zero). let flex: Vec = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect(); - let floor = (line.index..line.body).filter(|&j| j >= paragraph.marker && sg[j].is_space()).map(|j| sg[j].adv).fold(f64::MAX, f64::min); + let floor = + (line.index..line.body).filter(|&j| j >= paragraph.marker && is_justified_space(&sg[j])).map(|j| sg[j].adv).fold(f64::MAX, f64::min); fit = fit_line(&flex, room - width, spaces, floor); } else if room > width { // One word: its letter spacing and glyph scaling, then the gaps between its glyphs. @@ -820,11 +864,8 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin fit = fit_line(&flex, room - width, 0, 0.0); let used: f64 = flex.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); let rest = room - width - used; - let gaps = (line.index..line.body) - .filter(|&j| { - !held.contains(&j) && drawn_after(line.index, j, &after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) - }) - .count(); + let hyphen = line.hyphen.is_some(); + let gaps = (line.index..line.body).filter(|&j| is_letter_gap(j, held, &after, line.index, sg, hyphen)).count(); if gaps > 0 && rest > EPS { per_gap = rest / gaps as f64; } @@ -942,16 +983,13 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData advance += justification.fit.letter(&flex) + justification.fit.glyph(&flex); scale = justification.fit.glyph_scale(&flex); } - if glyph.is_space() { + if is_justified_space(glyph) { advance += justification.per_space; } else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical); } - } else if !line.held.contains(&glyph_index) - && drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) - { - // Between glyphs, never inside a tate-chu-yoko block (one cell). + } else if is_letter_gap(glyph_index, &line.held, &justification.after, index, sg, line.hyphen.is_some()) { advance += justification.per_gap; } } diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 0c8ffb4e2f..dde3593b9d 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -426,6 +426,21 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() { assert_eq!(split_end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in"); } +/// A zero-width joiner between two CJK characters takes no gap of its own: the layout closes the +/// one gap between the characters on either side, so the push-in counts five gaps (5 pt), short of +/// the 6 pt the line overflows by. +#[test] +fn kinsoku_shori_type_counts_no_gap_at_a_format_control() { + let text = "一二\u{200D}三四五」六"; + let mut glyphs = full_width(text); + glyphs[2].adv = 0.0; + let end = |width: f64| { + break_line(text, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushInFirst, false, false, &[]).0 + }; + assert_eq!(end(54.0), 5, "五 is pushed out with 」"); + assert_eq!(end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in"); +} + /// Mojikumi: the greedy breaker measures a line as it is set, with its last character narrowed /// (`line_end`): 」 set half width ends a measure of three and a half characters. Only the last /// glyph of the last cluster counts, and only where the line may end. diff --git a/crates/text/src/tests/devanagari.rs b/crates/text/src/tests/devanagari.rs index f7cca3093e..9e7f370f36 100644 --- a/crates/text/src/tests/devanagari.rs +++ b/crates/text/src/tests/devanagari.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use vectorcraft_doc::{CharStyle, TextKind}; use vectorcraft_geom::PathData; -fn devanagari_test_face(test: &str) -> Option> { +pub(super) fn devanagari_test_face(test: &str) -> Option> { let db = FontDb::global(); // The face must have OpenType (GSUB) Devanagari lookups. An AAT face such as macOS's Devanagari MT // shapes with `morx`, and हिन्दी comes out as three clusters because the conjunct न्दी is split in two. diff --git a/crates/text/src/tests/justify.rs b/crates/text/src/tests/justify.rs index 95646bc57b..dd80eed25b 100644 --- a/crates/text/src/tests/justify.rs +++ b/crates/text/src/tests/justify.rs @@ -130,6 +130,263 @@ fn a_justified_right_to_left_word_is_spread_between_its_drawn_letters() { assert!(gaps.iter().all(|&g| g > 1.0 && (g - gaps[0]).abs() < 1e-6), "{gaps:?}"); } +/// A word with a combining mark that has no precomposed form, `x` + U+0301: the base and the mark +/// are two glyphs of one shaped cluster at byte 1, between `a` (byte 0) and `z` (byte 4). +const MARKED: &str = "ax\u{301}z"; + +/// Where each glyph of the cluster at byte `b` sits from the cluster's first glyph. +fn cluster_offsets(l: &TextLayout, b: usize) -> Vec { + let glyphs: Vec<&PositionedGlyph> = l.glyphs.iter().filter(|g| g.byte == b).collect(); + glyphs.iter().map(|g| g.origin - glyphs[0].origin).collect() +} + +fn assert_same_offsets(a: &[Vec2], b: &[Vec2], what: &str) { + assert!(a.len() == b.len() && a.iter().zip(b).all(|(p, q)| (*p - *q).hypot() < 1e-6), "{what}: {a:?} vs {b:?}"); +} + +/// The precondition of the cluster tests: `MARKED` shapes the base and its mark as two glyphs +/// with one byte. +fn marked_cluster(l: &TextLayout) -> Vec { + let offsets = cluster_offsets(l, 1); + assert_eq!(offsets.len(), 2, "x and its mark are one cluster: {:?}", l.glyphs); + offsets +} + +/// The space Full Justify put between two clusters, from `a`'s first glyph to `b`'s, beyond the +/// distance the unspaced `natural` layout has between them. +fn added_gap(l: &TextLayout, natural: &TextLayout, a: usize, b: usize) -> f64 { + let d = |l: &TextLayout| (at(l, b).origin - at(l, a).origin).hypot(); + d(l) - d(natural) +} + +/// Full Justify spreads a single word between its clusters, never between a letter and its +/// combining mark: the mark keeps its place on its letter, the two gaps are equal, and the word +/// still reaches the measure. +#[test] +fn single_word_full_justify_keeps_combining_mark_with_base() { + let natural = layout(db(), &point(MARKED, JustificationOptions::default())); + let unspaced = marked_cluster(&natural); + let l = layout(db(), &area(MARKED, 300.0, Justify::JustifyAll, JustificationOptions::default())); + assert_same_offsets(&cluster_offsets(&l, 1), &unspaced, "the mark stays on its letter"); + let (first, second) = (added_gap(&l, &natural, 0, 1), added_gap(&l, &natural, 1, 4)); + assert!(first > 1.0 && (first - second).abs() < 1e-6, "{first} {second}"); + let z = at(&l, 4); + assert!((z.origin.x + at(&natural, 4).advance - 300.0).abs() < 1e-6, "{:?}", z.origin); +} + +/// Desired Letter Spacing on a ragged line, and the letter spacing range of a justified one, go +/// after each cluster, not between a letter and its mark. +#[test] +fn desired_and_fitted_letter_spacing_skip_shaped_cluster() { + let natural = layout(db(), &point(MARKED, JustificationOptions::default())); + let unspaced = marked_cluster(&natural); + + let desired = layout(db(), &point(MARKED, JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() })); + assert_same_offsets(&cluster_offsets(&desired, 1), &unspaced, "Desired Letter Spacing"); + let gap = at(&desired, 0).advance - at(&natural, 0).advance; + assert!(gap > 1.0, "{gap}"); + assert!((added_gap(&desired, &natural, 1, 4) - gap).abs() < 1e-6, "one gap after the cluster"); + + // 4 pt of room, less than the two gaps' Maximum (half a space each): letter spacing alone. + let w = natural.lines[0].x1 - natural.lines[0].x0 + 4.0; + let range = JustificationOptions { letter_spacing: [0.0, 0.0, 50.0], ..JustificationOptions::default() }; + let fitted = layout(db(), &area(MARKED, w, Justify::JustifyAll, range)); + assert_same_offsets(&cluster_offsets(&fitted, 1), &unspaced, "fitted letter spacing"); + let (first, second) = (added_gap(&fitted, &natural, 0, 1), added_gap(&fitted, &natural, 1, 4)); + assert!((first - 2.0).abs() < 1e-6 && (second - 2.0).abs() < 1e-6, "{first} {second}"); +} + +/// Right to left (a bidi override), Full Justify puts its gaps between the clusters as they are +/// drawn and leaves the mark on its letter. +#[test] +fn rtl_single_word_cluster_gap_follows_drawn_order() { + // RLO, then MARKED, then PDF: a at byte 3, the cluster at byte 4, z at byte 7. + let text = "\u{202E}ax\u{301}z\u{202C}"; + let natural = layout(db(), &point(text, JustificationOptions::default())); + let unspaced = cluster_offsets(&natural, 4); + assert_eq!(unspaced.len(), 2, "x and its mark are one cluster: {:?}", natural.glyphs); + assert!(at(&natural, 4).rtl, "the override sets the word right to left"); + let l = layout(db(), &area(text, 300.0, Justify::JustifyAll, JustificationOptions::default())); + assert_same_offsets(&cluster_offsets(&l, 4), &unspaced, "the mark stays on its letter"); + // Drawn from left to right: z, the cluster, a. + assert!(at(&l, 7).origin.x < at(&l, 4).origin.x && at(&l, 4).origin.x < at(&l, 3).origin.x); + let (first, second) = (added_gap(&l, &natural, 7, 4), added_gap(&l, &natural, 4, 3)); + assert!(first > 1.0 && (first - second).abs() < 1e-6, "{first} {second}"); + // The zero-width override marks take no gap: the word runs from edge to edge. + assert!(at(&l, 7).origin.x.abs() < 1e-6, "{:?}", at(&l, 7).origin); + assert!((at(&l, 3).origin.x + at(&natural, 3).advance - 300.0).abs() < 1e-6, "{:?}", at(&l, 3).origin); +} + +/// Desired Letter Spacing goes between the letters of a word wrapped in bidi override marks, +/// not after the zero-width marks or before the closing one. +#[test] +fn desired_letter_spacing_skips_bidi_format_controls() { + let text = "\u{202E}ax\u{301}z\u{202C}"; + let natural = layout(db(), &point(text, JustificationOptions::default())); + let spaced = layout(db(), &point(text, JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() })); + let gap = at(&spaced, 3).advance - at(&natural, 3).advance; + assert!(gap > 1.0, "{gap}"); + assert!((at(&spaced, 7).origin.x - at(&natural, 7).origin.x).abs() < 1e-6, "{:?}", at(&spaced, 7).origin); + let extent = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0; + assert!((extent(&spaced) - extent(&natural) - 2.0 * gap).abs() < 1e-6, "{} {} {gap}", extent(&spaced), extent(&natural)); +} + +/// A zero-width joiner at the start and end of a word, and a word joiner and a soft hyphen inside +/// it, take no letter spacing and no Full Justify gap: the space goes between a, b and c only. +#[test] +fn letter_spacing_and_full_justify_skip_joiners_and_soft_hyphens() { + // ZWJ (bytes 0..3), a (3), word joiner (4..7), b (7), soft hyphen (8..10), c (10), ZWJ (11..14). + let text = "\u{200D}a\u{2060}b\u{AD}c\u{200D}"; + let natural = layout(db(), &point(text, JustificationOptions::default())); + + let spaced = layout(db(), &point(text, JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() })); + let gap = at(&spaced, 3).advance - at(&natural, 3).advance; + assert!(gap > 1.0, "{gap}"); + assert!((at(&spaced, 3).origin.x - at(&natural, 3).origin.x).abs() < 1e-6, "{:?}", at(&spaced, 3).origin); + assert!((added_gap(&spaced, &natural, 7, 10) - gap).abs() < 1e-6, "one gap between b and c"); + let extent = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0; + assert!((extent(&spaced) - extent(&natural) - 2.0 * gap).abs() < 1e-6, "{} {} {gap}", extent(&spaced), extent(&natural)); + + let l = layout(db(), &area(text, 300.0, Justify::JustifyAll, JustificationOptions::default())); + let (first, second) = (added_gap(&l, &natural, 3, 7), added_gap(&l, &natural, 7, 10)); + assert!(first > 1.0 && (first - second).abs() < 1e-6, "{first} {second}"); + assert!(at(&l, 3).origin.x.abs() < 1e-6, "{:?}", at(&l, 3).origin); + assert!((at(&l, 10).origin.x + at(&natural, 10).advance - 300.0).abs() < 1e-6, "{:?}", at(&l, 10).origin); +} + +/// Every assigned default-ignorable character drawn with no width is a format control, the +/// zero-width space included; word spaces, the Hangul fillers and ordinary letters are not. +#[test] +fn format_controls_are_the_zero_width_default_ignorables() { + use crate::justify::is_format_control; + let controls = "\u{AD}\u{34F}\u{61C}\u{180E}\u{200B}\u{200C}\u{200D}\u{200E}\u{202E}\u{2060}\u{2065}\u{2066}\u{206F}"; + let more = "\u{FE0F}\u{FEFF}\u{1BCA0}\u{1D173}\u{E0001}\u{E007F}\u{E0100}"; + for ch in controls.chars().chain(more.chars()) { + assert!(is_format_control(ch), "U+{:04X}", ch as u32); + } + for ch in "a \u{2009}\u{3000}\u{2028}\u{3164}\u{FFA0}\u{301}".chars() { + assert!(!is_format_control(ch), "U+{:04X}", ch as u32); + } +} + +/// A zero-width space is a line break opportunity but not a word separator: a justified line +/// gives it no share of its room, Full Justify spreads a word with one between its visible letters, +/// and Desired Letter Spacing goes between the letters on either side of it. +#[test] +fn zero_width_space_takes_no_word_space_room() { + // a (0), b (1), zero-width space (2..5), c (5), d (6), space (7), e (8), f (9). + let natural = layout(db(), &point("ab\u{200B}cd ef", JustificationOptions::default())); + let l = layout(db(), &area("ab\u{200B}cd ef", 300.0, Justify::JustifyAll, JustificationOptions::default())); + assert!(added_gap(&l, &natural, 1, 5).abs() < 1e-6, "b and c stay together: {}", added_gap(&l, &natural, 1, 5)); + assert!((at(&l, 9).origin.x + at(&natural, 9).advance - 300.0).abs() < 1e-6, "the word space takes the room: {:?}", at(&l, 9).origin); + + // No word space: the zero-width space does not stand in for one. + let text = "ab\u{200B}cd"; + let natural = layout(db(), &point(text, JustificationOptions::default())); + let l = layout(db(), &area(text, 300.0, Justify::JustifyAll, JustificationOptions::default())); + let gaps = [added_gap(&l, &natural, 0, 1), added_gap(&l, &natural, 1, 5), added_gap(&l, &natural, 5, 6)]; + assert!(gaps[0] > 1.0 && gaps.iter().all(|g| (g - gaps[0]).abs() < 1e-6), "{gaps:?}"); + assert!((at(&l, 6).origin.x + at(&natural, 6).advance - 300.0).abs() < 1e-6, "{:?}", at(&l, 6).origin); + + let spaced = layout(db(), &point(text, JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() })); + let gap = at(&spaced, 0).advance - at(&natural, 0).advance; + assert!(gap > 1.0 && (added_gap(&spaced, &natural, 1, 5) - gap).abs() < 1e-6, "{gap} {}", added_gap(&spaced, &natural, 1, 5)); + + // It is still a place to break the line. + let w = natural.lines[0].x1 - natural.lines[0].x0 - 5.0; + let wrapped = layout(db(), &area(text, w, Justify::Left, JustificationOptions::default())); + assert_eq!(wrapped.lines.len(), 2); + assert_eq!(wrapped.lines[0].end, 5, "breaks after the zero-width space"); +} + +/// Desired Letter Spacing puts the gap between b and c on the zero-width space, so a line that +/// breaks there ends at b's own advance and wraps where the unspaced text does. +#[test] +fn desired_letter_spacing_ends_a_line_broken_at_a_zero_width_space_without_a_gap() { + // a (0), b (1), zero-width space (2..5), c (5), d (6). + let text = "ab\u{200B}cd"; + let desired = JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() }; + let natural = layout(db(), &point(text, JustificationOptions::default())); + let spaced = layout(db(), &point(text, desired.clone())); + let gap = at(&spaced, 0).advance - at(&natural, 0).advance; + assert!(gap > 1.0, "{gap}"); + // Room for a, its gap and b, and half a gap more: not for a second gap after b. + let w = at(&natural, 0).advance + gap + at(&natural, 1).advance + 0.5 * gap; + let unspaced = layout(db(), &area(text, w, Justify::Left, JustificationOptions::default())); + assert_eq!(unspaced.lines.first().map(|l| l.end), Some(5), "the unspaced text breaks after the zero-width space"); + let wrapped = layout(db(), &area(text, w, Justify::Left, desired)); + assert_eq!(wrapped.lines.len(), 2, "{:?}", wrapped.lines); + assert_eq!(wrapped.lines[0].end, 5, "breaks after the zero-width space"); + assert!((at(&wrapped, 1).advance - at(&natural, 1).advance).abs() < 1e-6, "b keeps its own advance: {}", at(&wrapped, 1).advance); + let b = at(&wrapped, 1); + let end = b.origin.x + b.advance; + assert!((end - (at(&natural, 0).advance + gap + at(&natural, 1).advance)).abs() < 1e-6, "the line ends at b: {end}"); +} + +/// A single word under Full Justify that breaks at a soft hyphen spreads its first line between its +/// letters and the hyphen drawn after the last one, all with the same gap. +#[test] +fn single_word_full_justify_spreads_up_to_the_drawn_hyphen() { + // a (0), b (1), c (2), soft hyphen (3..5), then d at byte 5. + let text = "abc\u{AD}defghij"; + let hyphenated = layout(db(), &point("abc-", JustificationOptions::default())); + let w = hyphenated.lines[0].x1 - hyphenated.lines[0].x0 + 30.0; + // The same first line, set ragged: the distances justification adds to. + let natural = layout(db(), &area(text, w, Justify::Left, JustificationOptions::default())); + let l = layout(db(), &area(text, w, Justify::JustifyLeft, JustificationOptions::default())); + for layout in [&natural, &l] { + assert!(layout.lines.len() >= 2 && layout.lines[0].end == 5, "the first line ends at the soft hyphen: {:?}", layout.lines); + } + let hyphen = |l: &TextLayout| l.glyphs[l.lines[0].glyph_end - 1].clone(); + let ab = added_gap(&l, &natural, 0, 1); + let bc = added_gap(&l, &natural, 1, 2); + let c_hyphen = (hyphen(&l).origin.x - at(&l, 2).origin.x) - (hyphen(&natural).origin.x - at(&natural, 2).origin.x); + assert!(ab > 1.0 && (ab - bc).abs() < 1e-6 && (ab - c_hyphen).abs() < 1e-6, "{ab} {bc} {c_hyphen}"); + let end = hyphen(&l).origin.x + hyphen(&l).advance; + assert!((end - w).abs() < 1e-6, "the hyphen reaches the edge: {end} {w}"); +} + +/// In vertical type, Full Justify spreads a word down the column between its clusters and leaves +/// the mark on its letter. +#[test] +fn vertical_single_word_cluster_gap_follows_drawn_order() { + let vertical = |t: &mut TextObject| t.vertical = true; + let mut plain = point(MARKED, JustificationOptions::default()); + vertical(&mut plain); + let natural = layout(db(), &plain); + let unspaced = marked_cluster(&natural); + let mut t = area(MARKED, 100.0, Justify::JustifyAll, JustificationOptions::default()); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 300.0).to_path(0.1)) }; + vertical(&mut t); + let l = layout(db(), &t); + assert_same_offsets(&cluster_offsets(&l, 1), &unspaced, "the mark stays on its letter"); + assert!(at(&l, 0).origin.y < at(&l, 1).origin.y && at(&l, 1).origin.y < at(&l, 4).origin.y, "down the column"); + let (first, second) = (added_gap(&l, &natural, 0, 1), added_gap(&l, &natural, 1, 4)); + assert!(first > 1.0 && (first - second).abs() < 1e-6, "{first} {second}"); +} + +/// A Devanagari syllable whose vowel sign is drawn before its consonant (कि) is one cluster of two +/// glyphs: Full Justify keeps them together. Skipped without an installed Devanagari face. +#[test] +fn single_word_full_justify_keeps_conjunct_cluster() { + let Some(face) = super::devanagari::devanagari_test_face("single_word_full_justify_keeps_conjunct_cluster") else { return }; + // कि (bytes 0..6) and क (byte 6). + let text = "\u{915}\u{93F}\u{915}"; + let styled = |mut t: TextObject| { + for run in &mut t.runs { + run.style.font_family = face.family.clone(); + run.style.font_style = face.style.clone(); + } + t + }; + let natural = layout(db(), &styled(point(text, JustificationOptions::default()))); + let unspaced = cluster_offsets(&natural, 0); + assert!(unspaced.len() >= 2, "कि is one cluster of several glyphs: {:?}", natural.glyphs); + let l = layout(db(), &styled(area(text, 300.0, Justify::JustifyAll, JustificationOptions::default()))); + assert_same_offsets(&cluster_offsets(&l, 0), &unspaced, "the syllable stays together"); + assert!(added_gap(&l, &natural, 0, 6) > 1.0); +} + /// Single Word Justification: a justified line holding one word is spread between its letters /// (Full Justify, the default) or aligned as chosen. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d42a4d988b..0e66a78d6c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -649,6 +649,7 @@ since 2026-10-01. - **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides stay, and text that still has the old value of an attribute the new definition leaves out goes back to the Normal style's value. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles dragged onto the panel's buttons:** a style dragged from the Character Styles or Paragraph Styles panel onto Create New Style is copied (`charStyle.duplicate`, `paraStyle.duplicate`, which name the copy ` copy`) and the copy is highlighted; dragged onto Delete it is deleted (`charStyle.delete`, `paraStyle.delete`); the Normal style is not deleted. Each drop is one undo step. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading () also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`. +- **Letter spacing and Full Justify between shaped clusters:** Desired Letter Spacing on any line, the letter spacing range of a justified line, and Single Word Justification's Full Justify add space only between shaped clusters, in horizontal, right-to-left and vertical type, so a combining mark stays on its letter and a Devanagari syllable's glyphs stay together. They add no space next to a zero-width format control (a soft hyphen, a bidi mark, embedding, override or isolate, a zero-width space, a joiner or non-joiner, a word joiner, a variation selector, a tag, the byte order mark), so a word wrapped in an override still reaches both edges of a justified line, and a Kinsoku push-in counts no gap at one either. A zero-width space is a line break opportunity but not a word separator ([CSS Text Level 3](https://www.w3.org/TR/css-text-3/#word-separator) does not list it), so a justified line gives it no share of its room. Desired Letter Spacing between the letters on either side of a zero-width space goes after the zero-width space, so a line that breaks there ends at its last letter, and the line breaker does not measure a gap after it. A line that Full Justify spreads and that ends in a drawn hyphen puts the same gap between its last letter and the hyphen. The cluster boundary is VectorCraft's own rule, based on the typographic character unit of [CSS Text Level 3](https://www.w3.org/TR/css-text-3/#typographic-character-unit). Tests `tests::justify::single_word_full_justify_keeps_combining_mark_with_base`, `desired_and_fitted_letter_spacing_skip_shaped_cluster`, `rtl_single_word_cluster_gap_follows_drawn_order`, `vertical_single_word_cluster_gap_follows_drawn_order`, `single_word_full_justify_keeps_conjunct_cluster`, `desired_letter_spacing_skips_bidi_format_controls`, `letter_spacing_and_full_justify_skip_joiners_and_soft_hyphens`, `format_controls_are_the_zero_width_default_ignorables`, `zero_width_space_takes_no_word_space_room`, `desired_letter_spacing_ends_a_line_broken_at_a_zero_width_space_without_a_gap`, `single_word_full_justify_spreads_up_to_the_drawn_hyphen` and `layout::wrapping_tests::kinsoku_shori_type_counts_no_gap_at_a_format_control`. Public reference: the help page (). - **The text style panel menus' Duplicate and Delete:** in the Character Styles and Paragraph Styles panel menus, Duplicate highlights the copy, as Create New Style highlights the new style, and Delete leaves no style highlighted, as the Delete button does, so Duplicate, Clear Overrides, Redefine and Style Options are disabled until another style is clicked. A highlighted style that is renamed in Style Options or deleted by a command is no longer highlighted either. The tests `panels::text_styles::tests::panel_menu_duplicate_and_delete_update_the_highlighted_style` and `a_highlighted_style_that_is_no_longer_listed_is_not_highlighted` cover this. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), which lists Alt-click for opening panel options and names Actions, Brushes, Swatches and Symbols as exceptions. From 6208a8cc3e0e686e72136ed627fc962580d81d72 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:08:21 -0800 Subject: [PATCH 68/79] EPS import: charpath, cshow, kshow and glyphshow read named CIDFont type by its CMap - `charpath` in a CIDFont only named, over a predefined CMap, adds the outlines of the characters its codes stand for, placed as `show` sets them, and moves the point as `show` does. It added no outlines. - `cshow` gives its procedure each code of such type, two bytes for a two-byte code, with the code's width. It split two-byte codes into single bytes. - `glyphshow` sets the named character itself. It set the character's UTF-8 bytes as Latin-1 letters. - `kshow` runs its procedure between each two codes of such type, with the two codes' values. It didn't run it. The procedure sees the font the file set as current, and a font it sets stays current. - A name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name (as a resource or with `definefont`), or the name has a hyphen and an installed face that covers Japanese has it. `Foo-Bold-H` stays a font of the whole name. - In a CMap that can't be read (`GBT-H`), each code moves the point one em, along the line or down the column; a tab or carriage return byte is a code of its own. - One parser reads the names of the predefined Unicode CMaps, for EPS and legacy `.ai` type. - Outlines added to a path by `charpath` stop at the path's point limit. - docs/file-format-parity.md, docs/gaps.md and docs/mcp.md describe type in named CIDFonts over the predefined CMaps. --- crates/eps/src/import/cid.rs | 35 +-- crates/eps/src/import/interp.rs | 4 + crates/eps/src/import/predefined_cmap.rs | 24 +- crates/eps/src/import/tests_cid.rs | 114 ++++++++- crates/eps/src/import/text.rs | 292 +++++++++++++++++------ docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- docs/mcp.md | 2 +- 8 files changed, 349 insertions(+), 126 deletions(-) diff --git a/crates/eps/src/import/cid.rs b/crates/eps/src/import/cid.rs index a54c11ae2b..4c00486de9 100644 --- a/crates/eps/src/import/cid.rs +++ b/crates/eps/src/import/cid.rs @@ -18,7 +18,6 @@ use vectorcraft_geom::BezPath; use super::interp::{Interp, matrix_obj}; use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err}; -use super::predefined_cmap::Unicode; /// Most codespace ranges, character and notdef mappings a CMap may hold. const MAX_RANGES: usize = 1 << 16; @@ -211,32 +210,6 @@ fn number(data: &[u8], at: usize, n: usize) -> Option { (n <= 8).then(|| bytes.iter().fold(0u64, |v, b| v << 8 | u64::from(*b))) } -/// The encoding of predefined Unicode CMap `name` and whether it is vertical: one of Adobe's -/// public CMap resources whose name says its codes are Unicode: `Uni` and a character -/// collection, then `-UCS2`, `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), -/// then `-H` or `-V` (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: -/// ). -pub(super) fn unicode_cmap(name: &str) -> Option<(Unicode, bool)> { - let (rest, dir) = name.rsplit_once('-')?; - let vertical = match dir { - "H" => false, - "V" => true, - _ => return None, - }; - let rest = rest.strip_suffix("-HW").unwrap_or(rest); - let (collection, form) = rest.rsplit_once('-')?; - if !(collection.len() > 3 && collection.starts_with("Uni") && !collection.contains('-')) { - return None; - } - let form = match form { - "UCS2" | "UTF16" => Unicode::Utf16, - "UTF8" => Unicode::Utf8, - "UTF32" => Unicode::Utf32, - _ => return None, - }; - Some((form, vertical)) -} - /// A predefined Identity CMap: two-byte codes, each the CID of its own value; vertical for /// `Identity-V`. pub(super) fn identity(name: &str, vertical: bool) -> Obj { @@ -396,11 +369,9 @@ impl Interp<'_> { return Ok(d.clone()); } let text = name.text().map(|t| t.to_string()).unwrap_or_default(); - // Another predefined CMap the file doesn't define, without its mappings: its codes are - // read by its name when type is set (see `text`). - let predefined = unicode_cmap(&text) - .map(|(_, vertical)| vertical) - .or_else(|| super::predefined_cmap::known(&text).then(|| super::predefined_cmap::vertical(&text))); + // A predefined CMap the file doesn't define, without its mappings: its codes are read by + // its name when type is set (see `text`). + let predefined = super::predefined_cmap::known(&text).then(|| super::predefined_cmap::vertical(&text)); match predefined { Some(vertical) => { let mut d = Dict::new(); diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 58708bb97e..6b96aabc25 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -95,6 +95,9 @@ pub(crate) struct Interp<'a> { /// The predefined CMaps type was set in so far, by name, with how their strings decode /// (`None` when one can't be read; see `text`). pub predefined_cmaps: Vec<(Rc, Option>)>, + /// The font a `kshow` procedure sees as current while type in a CIDFont only named is set in + /// a font of its own (see `text`). + pub kshow_font: Option, pub g: GState, pub saved: Vec, pub out: Out, @@ -236,6 +239,7 @@ impl<'a> Interp<'a> { cid_subrs: vec![], cmap_font: 0, predefined_cmaps: vec![], + kshow_font: None, type42_counted: HashSet::new(), g, saved: vec![], diff --git a/crates/eps/src/import/predefined_cmap.rs b/crates/eps/src/import/predefined_cmap.rs index 1a9b3b95be..19525fdf36 100644 --- a/crates/eps/src/import/predefined_cmap.rs +++ b/crates/eps/src/import/predefined_cmap.rs @@ -157,7 +157,7 @@ pub(super) enum Unicode { /// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`, /// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V` /// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: ). -fn unicode_cmap(name: &str) -> Option { +pub(super) fn unicode_cmap(name: &str) -> Option { let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?; let rest = rest.strip_suffix("-HW").unwrap_or(rest); let (collection, form) = rest.rsplit_once('-')?; @@ -325,6 +325,28 @@ pub(super) fn known(cmap: &str) -> bool { unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other() } +/// The codes of string `bytes` in predefined CMap `cmap` when its mappings can't be read, each +/// standing for no character: as long as the code space range it falls in ([`code_space`]; a byte +/// that starts no code is a code of its own), or two bytes when the code space isn't listed (the +/// row-and-cell codes of `GBT-H` and `CNS2-H`). A 0x0D or 0x09 byte is always a code of its own. +pub(super) fn unread_codes(cmap: &str, bytes: &[u8]) -> Decoded { + let space = code_space(cmap); + let mut out = Decoded::default(); + let mut at = 0; + while let Some(rest) = bytes.get(at..).filter(|r| !r.is_empty()) { + let len = match space { + // A 0x0D or 0x09 byte is a code of its own, as [`CMapCodes::each_code`] reads it. + _ if rest.first().and_then(|&b| control(b)).is_some() => 1, + Some(space) => code_len(space, rest).map_or(1, usize::from), + None => 2, + }; + let code = rest.get(..len).unwrap_or(rest); + out.push(code.iter().fold(0u32, |c, &b| c << 8 | u32::from(b)), None); + at += code.len(); + } + out +} + /// Whether predefined CMap `cmap` is a vertical CMap (`-V`, or the row-and-cell `V`). pub(super) fn vertical(cmap: &str) -> bool { cmap == "V" || cmap.ends_with("-V") diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index 9e452c4bf4..25ed81c2b0 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -426,8 +426,6 @@ fn named_cidfonts_over_predefined_cmaps_set_their_characters_as_type() { ("83pv-RKSJ-H", "93fa967b41", "日本A"), ("90ms-RKSJ-H", "93fa", "日"), ("EUC-H", "c6fc", "日"), - // JIS row and cell: 日 is 0x467C. - ("H", "467c", "日"), ("GBK-EUC-H", "d6d0", "中"), ("B5pc-H", "a4a4", "中"), ("ETen-B5-H", "a4a4", "中"), @@ -448,6 +446,9 @@ fn named_cidfonts_over_predefined_cmaps_set_their_characters_as_type() { } let r = super::tests::read("/X /GBK-EUC-H [/NotInFile] composefont 20 scalefont setfont 10 20 moveto show"); assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::(), "中A"); + // JIS row and cell: 日 is 0x467C. + let r = super::tests::read("/X /H [/Ryumin-Light] composefont 20 scalefont setfont 10 20 moveto <467c> show"); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::(), "日"); // The point moves past the type, along the line. check("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont <93fa967b> stringwidth abs 0.01 lt exch dup 5 gt exch 60 lt and and"); } @@ -478,8 +479,34 @@ fn named_cidfonts_over_predefined_cmaps_take_the_show_operators_spacing() { measured(&format!( "{gb} 10 20 moveto <56505650> show currentpoint pop 10 20 moveto 7 0 16#5650 <56505650> widthshow currentpoint pop sub -14 sub abs 0.01 lt" )); - // `xshow` moves 30 a code. + // `awidthshow` adds 5 to each code and 7 more to the space. + measured(&format!( + "{set} 10 20 moveto <93fa20967b> show currentpoint pop 10 20 moveto 7 0 32 5 0 <93fa20967b> awidthshow currentpoint pop sub -22 sub abs 0.01 lt" + )); + // `xshow` moves 30 a code, `yshow` 10 up a code, `xyshow` both. measured(&format!("{set} 10 20 moveto <93fa967b> [30 30] xshow currentpoint pop 70 sub abs 0.01 lt")); + measured(&format!("{set} 10 20 moveto <93fa967b> [10 10] yshow currentpoint 40 sub abs 0.01 lt exch 10 sub abs 0.01 lt and")); + measured(&format!("{set} 10 20 moveto <93fa967b> [30 10 30 10] xyshow currentpoint 40 sub abs 0.01 lt exch 70 sub abs 0.01 lt and")); + // `kshow` runs its procedure between each two codes: it moves the point 100 more here, and a + // font it sets stays current. + measured(&format!( + "{set} 10 20 moveto <93fa967b> show currentpoint 10 20 moveto {{pop pop 100 0 rmoveto}} <93fa967b> kshow currentpoint \ + 3 -1 roll sub abs 0.01 lt 3 1 roll sub -100 sub abs 0.01 lt and" + )); + measured(&format!( + "{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont}} <93fa967b> kshow currentfont /FontName get /Helvetica eq" + )); + // The procedure sees the font the file set as current, and the next one the font an earlier + // one set. + measured(&format!("{set} 10 20 moveto {{pop pop currentfont /FontName get /seen exch def}} <93fa967b> kshow seen /Ryumin-Light-90ms-RKSJ-H eq")); + measured(&format!( + "{set} 10 20 moveto /n 0 def {{pop pop /n n 1 add def n 1 eq {{/Helvetica findfont 10 scalefont setfont}} {{currentfont /FontName get /seen exch def}} ifelse}} \ + <93fa967b41> kshow seen /Helvetica eq" + )); + // The procedure gets the two codes' values (left on the stack here). + measured(&format!( + "{set} 10 20 moveto [ {{}} <93fa967b41> kshow ] dup length 4 eq exch aload pop 65 eq exch 16#967b eq and exch 16#967b eq and exch 16#93fa eq and and" + )); // Down the column: `ashow`'s 5 down each code. let set = "/Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont"; measured(&format!( @@ -530,10 +557,15 @@ fn named_cid_type_is_set_alike_by_every_show_operator() { let r = super::tests::read(&format!("{set} <00660069> {show}")); let t = texts(&r); assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["fi"], "{show}"); - t[0].runs.iter().map(|r| r.style.features.clone()).collect::>() + // Spacing that differs from the advances splits the type into runs of the same features. + let mut features = t[0].runs.iter().map(|r| r.style.features.clone()).collect::>(); + features.dedup(); + features }; let plain = features("show"); - assert_eq!(plain, features("0 0 3 -1 roll ashow")); + for show in ["0 0 3 -1 roll ashow", "0 0 32 4 -1 roll widthshow", "0 0 32 0 0 6 -1 roll awidthshow", "[25 25] xshow", "{pop pop} exch kshow"] { + assert_eq!(plain, features(show), "{show}"); + } assert!(plain.iter().all(|f| f.iter().any(|f| f == "-liga")), "{plain:?}"); check(&format!( "gsave nulldevice {set} <00660069> stringwidth 10 20 moveto 0 0 <00660069> ashow currentpoint grestore \ @@ -541,17 +573,77 @@ fn named_cid_type_is_set_alike_by_every_show_operator() { )); } -/// A font name ending in the row-and-cell CMap `H` after a name without a hyphen stays a font of -/// the whole name, its strings read byte by byte. Type in a predefined CMap whose mappings aren't -/// available is left out with a warning, and a long string in a CID-keyed font over a predefined -/// CMap shown in a loop runs out of the budget. +/// `charpath`, `cshow` and `glyphshow` in a CID-keyed font over a predefined CMap, its CIDFont only +/// named: `charpath` adds the outlines of the characters the codes stand for and moves the point +/// as `show` does; `cshow`'s procedure gets each code (two bytes for a two-byte code) with its +/// width; `glyphshow` sets the named character itself. +#[test] +fn named_cid_type_in_charpath_cshow_and_glyphshow() { + let set = "/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont"; + let r = super::tests::read(&format!("{set} 10 20 moveto <4142> true charpath fill")); + assert!(texts(&r).is_empty(), "{:?}", all(&r.document)); + assert!(all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Path { .. })), "{:?}", all(&r.document)); + check(&format!( + "gsave nulldevice {set} 10 20 moveto <93fa41> show currentpoint newpath 10 20 moveto <93fa41> false charpath currentpoint grestore \ + 3 -1 roll sub abs 0.01 lt 3 1 roll sub abs 0.01 lt and" + )); + // Down a column a code that stands for no character (0xFD) moves the outlines one em, as it + // moves the type `show` sets. + let top = |s: &str| format!("newpath 10 100 moveto <{s}> false charpath pathbbox 4 1 roll pop pop pop"); + check(&format!( + "gsave nulldevice /Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont {} {} grestore sub -20 sub abs 0.5 lt", + top("fd41"), + top("41") + )); + // The procedure leaves each code: 日 (0x93FA), then A. + check(&format!("{set} [ {{pop pop}} <93fa41> cshow ] dup length 2 eq exch dup 0 get 16#93fa eq exch 1 get 65 eq and and")); + check(&format!("{set} 0 {{pop exch pop add}} <93fa967b41> cshow <93fa967b41> stringwidth pop sub abs 0.01 lt")); + let r = super::tests::read(&format!("{set} 10 20 moveto /uni00E9 glyphshow")); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::>(), ["é"]); +} + +/// A font name ending in the row-and-cell CMap `H` or `V` stays a font of the whole name, its +/// strings read byte by byte, unless the part before the CMap names a CIDFont the file defines (or +/// an installed face that covers Japanese). Type in a predefined CMap whose mappings aren't +/// available is left out with a warning, each code moving the point one em, and a long string in a +/// CID-keyed font over a predefined CMap shown in a loop runs out of the budget. #[test] fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() { - let r = super::tests::read("/Futura-H findfont 20 scalefont setfont 10 20 moveto (AB) show"); - assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::>(), ["AB"]); + for (font, family, style) in [("Futura-H", "Futura", "H"), ("Foo-Bold-H", "Foo", "Bold-H"), ("Foo-Condensed-V", "Foo", "Condensed-V")] { + let r = super::tests::read(&format!("/{font} findfont 20 scalefont setfont 10 20 moveto (AB) show")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["AB"], "{font}"); + let s = &t[0].runs[0].style; + assert_eq!((s.font_family.as_str(), s.font_style.as_str()), (family, style), "{font}"); + assert!(!t[0].vertical, "{font}"); + } + // The file defines the CIDFont `Ryumin-Light`, as a resource or as a font: `Ryumin-Light-H` is + // it over the CMap `H`. + // A CIDFont name without a hyphen too, when the file defines it. + let cid_font = |name: &str| format!("/{name} << /CIDFontName /{name} /CIDFontType 0 /FontType 9 /FontMatrix [0.001 0 0 0.001 0 0] >>"); + let defines = [ + format!("{} /CIDFont defineresource pop /Ryumin-Light-H", cid_font("Ryumin-Light")), + format!("{} definefont pop /Ryumin-Light-H", cid_font("Ryumin-Light")), + format!("{} /CIDFont defineresource pop /Foo-H", cid_font("Foo")), + ]; + for define in defines { + let r = super::tests::read(&format!("{define} findfont 20 scalefont setfont 10 20 moveto <467c> show")); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::>(), ["日"], "{define}: {:?}", r.warnings); + } let r = super::tests::read("/Ryumin-Light-GBT-H findfont 20 scalefont setfont 10 20 moveto (AB) show 0 0 10 10 rectfill"); assert!(texts(&r).is_empty(), "{:?}", all(&r.document)); assert_eq!(super::tests::program_warnings(&r.warnings), [super::text::UNREAD_CMAP], "{:?}", r.warnings); + // Two two-byte codes: two ems along the line, or down the column (a square drawn when the + // point moved that far). + for cond in [ + "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <21212121> stringwidth abs 0.01 lt exch 40 sub abs 0.01 lt and", + "/Ryumin-Light-GBT-V findfont 20 scalefont setfont 10 100 moveto <21212121> show currentpoint 60 sub abs 0.01 lt exch 10 sub abs 0.01 lt and", + // A tab is a code of its own: three codes. + "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <09092121> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and", + ] { + let r = super::tests::read(&format!("{cond} {{ 0 0 10 10 rectfill }} if")); + assert!(all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Path { .. })), "{cond}"); + } super::tests::runs_out_of_budget("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont { 100000 string stringwidth pop pop } loop"); } diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index ee9b0fc759..73fc4776d0 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -171,8 +171,8 @@ impl Interp<'_> { ResourceForAll => self.resource_for_all()?, Show | AShow | WidthShow | AWidthShow | XShow | YShow | XYShow | KShow => { let (s, spacing) = self.show_operands(op)?; - if let Some((codes, font, vertical)) = self.named_cid_type(&s)? { - self.show_named_cid(&codes, &font, vertical, &spacing)?; + if let Some(named) = self.named_cid_type(&s)? { + self.show_named_cid(&named, &spacing, Draw::Fill)?; } else if let Some(glyphs) = self.composite_glyphs(&s)? { self.show_composite(&glyphs, &spacing)?; } else { @@ -186,6 +186,23 @@ impl Interp<'_> { // The procedure shows or places each character: it gets its code and width. let s = self.pop_str()?.to_vec(); let proc = self.pop_proc()?; + // In a CIDFont only named, a code may be several bytes: the procedure gets each + // code's value. + if let Some(named) = self.named_cid_type(&s)? { + let mut chars = named.codes.text.chars(); + for &(code, n) in &named.codes.codes { + let mut one = Decoded::default(); + one.push(code, chars.by_ref().take(n)); + let v = self.named_cid_width(&named, &one)?; + self.push(Obj::Int(i64::from(code)))?; + self.push_num(v.x)?; + self.push_num(v.y)?; + if !self.body(&proc)? { + break; + } + } + return Ok(()); + } for b in s { let v = self.width_of(&[b])?; self.push(Obj::Int(i64::from(b)))?; @@ -208,7 +225,14 @@ impl Interp<'_> { } None => { let ch = glyph_char(&name); - self.show(ch.encode_utf8(&mut [0; 4]).as_bytes())?; + // In a CIDFont only named, the character is set as it is, not read through + // the CMap. + if let Some(mut named) = self.named_cid_font()? { + named.codes.push(0, Some(ch).filter(|c| !c.is_control())); + self.show_named_cid(&named, &Spacing::Plain, Draw::Fill)?; + } else { + self.show(ch.encode_utf8(&mut [0; 4]).as_bytes())?; + } } } } @@ -283,11 +307,8 @@ impl Interp<'_> { /// How far `s` moves the current point (user space). fn width_of(&mut self, s: &[u8]) -> Res { - if let Some((codes, font, vertical)) = self.named_cid_type(s)? { - let saved = self.g.font.replace(font); - let r = self.named_cid_width(&codes, vertical); - self.g.font = saved; - return r; + if let Some(named) = self.named_cid_type(s)? { + return self.named_cid_width(&named, &named.codes); } if let Some(glyphs) = self.composite_glyphs(s)? { let mut w = Vec2::ZERO; @@ -399,25 +420,23 @@ impl Interp<'_> { /// is a predefined CMap, the CID-keyed font of those two (the usual name of a CID-keyed font, /// as `composefont` makes them: PLRM 3rd ed., chapter 8; the CMaps are Adobe's public CMap /// resources, ); else a font of that name. - /// A name an installed face has is that face's, and a name split at a bare `H` or `V` (the - /// row-and-cell CMaps) needs a CIDFont name with a hyphen in it (`Ryumin-Light-H`), so - /// `Futura-H` stays a font of that name. + /// A name that ends in a predefined CMap other than a Unicode CMap and that an installed face + /// has is that face's ([`Self::predefined_split`]). fn find_font(&mut self, k: &Obj) -> Res { let key = k.key().ok_or(PsError::Ps("typecheck", "findfont".into()))?; if let Some(Obj::Dict(d)) = self.fonts.borrow().get(&key) { return Ok(d.clone()); } let text = k.text().map(|t| t.to_string()).unwrap_or_default(); - let split = text + let unicode = text .match_indices('-') .map(|(i, _)| i) - .find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some()) - .and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))) - .or_else(|| { - let (cid_font, cmap) = super::predefined_cmap::composite_font(&text)?; - let installed = vectorcraft_text::FontDb::global().find_postscript(&text).is_some(); - (!installed && !(matches!(cmap, "H" | "V") && !cid_font.contains('-'))).then_some((cid_font, cmap)) - }); + .find(|&i| i > 0 && super::predefined_cmap::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some()) + .and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))); + let split = match unicode { + Some(split) => Some(split), + None => self.predefined_split(&text), + }; if let Some((cid_font, cmap)) = split { let cmap = self.find_cmap(&Obj::name(cmap))?; let dep = self.find_cid_font(&Obj::name(cid_font))?; @@ -434,6 +453,32 @@ impl Interp<'_> { Ok(font) } + /// Font name `name` as a CIDFont's name and the name of a predefined CMap other than a Unicode + /// CMap ([`super::predefined_cmap::composite_font`]), unless an installed face has the whole + /// name. A name that ends in the row-and-cell CMap `H` or `V` splits only when the part before + /// it names a CIDFont the file defines, or has a hyphen and names an installed face that covers + /// Japanese; otherwise it stays a font of the whole name (`Futura-H`, `Foo-Bold-H`), its + /// strings read byte by byte. + fn predefined_split<'a>(&mut self, name: &'a str) -> Option<(&'a str, &'a str)> { + let (cid_font, cmap) = super::predefined_cmap::composite_font(name)?; + let db = vectorcraft_text::FontDb::global(); + if db.find_postscript(name).is_some() { + return None; + } + if matches!(cmap, "H" | "V") { + let key = Key::name(cid_font); + // As a CIDFont resource, or as a font (`definefont`), as `composefont` finds it. + let cid_font_in = |d: &DictRef| matches!(d.borrow().get(&key), Some(Obj::Dict(f)) if f.borrow().contains_key(&Key::name("CIDFontType"))); + let defined = self.instances(&Obj::name("CIDFont")).is_ok_and(|d| d.borrow().contains_key(&key)) || cid_font_in(&self.fonts); + // 日 is in the row-and-cell CMaps' character set (JIS X 0208). + let installed = || db.find_postscript(cid_font).is_some_and(|face| face.covers('日')); + if !(defined || (cid_font.contains('-') && installed())) { + return None; + } + } + Some((cid_font, cmap)) + } + /// The current font's name and its matrix for a font of one unit (user space). fn font(&mut self) -> Res<(String, Affine)> { let font = match self.g.font.clone() { @@ -575,15 +620,25 @@ impl Interp<'_> { codes.push(u32::from(code), c.filter(|c| !c.is_control())); } let widths: Option> = plain.map(|(_, glyphs)| glyphs.iter().map(|g| g.width).collect()); - self.show_codes(&codes, widths.as_deref(), spacing, false, s) + self.show_codes(&codes, widths.as_deref(), spacing, false, s, Draw::Fill) } /// The codes `codes` (each standing for some of its characters, or none) shown in the current /// font as [`Self::show_plain`] shows a string's, spaced as `spacing` says, each code `widths` /// wide when given (in glyph space, one unit an em), else as wide as its characters' advances; - /// as vertical type when `vertical`, the codes moving the point down the column. `kern` is the - /// string `kshow` gives its procedure the codes of. - fn show_codes(&mut self, codes: &Decoded, widths: Option<&[f64]>, spacing: &Spacing, vertical: bool, kern: &[u8]) -> Res { + /// as vertical type when `vertical`, the codes moving the point down the column. `kern` holds + /// the codes `kshow` gives its procedure, one for each code. With `draw` [`Draw::Path`] the + /// type's glyph outlines are added to the current path, placed as the type would be + /// (`charpath`). + fn show_codes>( + &mut self, + codes: &Decoded, + widths: Option<&[f64]>, + spacing: &Spacing, + vertical: bool, + kern: &[C], + draw: Draw, + ) -> Res { let at = self.user_point()?; let (_, f1) = self.font()?; let text = &codes.text; @@ -627,8 +682,9 @@ impl Interp<'_> { return ps_err("undefinedresult", "a show"); } let across = moves.iter().any(|m| m.2.abs() > size * 1e-3); - // Nothing shows on the null device (`nulldevice`, a Type 3 glyph only measured). - let painted = !self.g.paint.is_none() && !self.g.null; + // Nothing shows on the null device (`nulldevice`, a Type 3 glyph only measured); + // `charpath` adds the outlines there too. + let painted = draw == Draw::Path || (!self.g.paint.is_none() && !self.g.null); if across { // Moves across the baseline (`yshow`, `xyshow`): each character where the file puts it. let mut pen = at; @@ -637,8 +693,12 @@ impl Interp<'_> { let c: String = chars.by_ref().take(n).collect(); if painted && !c.trim().is_empty() { let (t, ..) = self.set_text_in(&c, pen, vertical)?; - let clips = self.g.clips.clone(); - self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); + if draw == Draw::Path { + self.type_path(&t)?; + } else { + let clips = self.g.clips.clone(); + self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); + } } pen += *d; } @@ -686,27 +746,64 @@ impl Interp<'_> { if lead != Vec2::ZERO { t.xf = Affine::translate(self.xf() * Point::new(lead.x, lead.y) - self.xf() * Point::ZERO) * t.xf; } - let clips = self.g.clips.clone(); - if self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips).is_some() - && let Some(after) = self.out.drawn.get_mut(start..) - { - after.rotate_right(1); + if draw == Draw::Path { + self.type_path(&t)?; + } else { + let clips = self.g.clips.clone(); + if self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips).is_some() + && let Some(after) = self.out.drawn.get_mut(start..) + { + after.rotate_right(1); + } } } self.g.cur = Some(self.xf() * (at + total)); Ok(()) } - /// `kshow` between character `i` of `s` and the next: its procedure run with the two codes - /// and the current point at user point `pen` (PLRM 3rd ed., `kshow`: the procedure may - /// change the current point, as kerning does) → how far it moved the point (user space). + /// The glyph outlines of point type `t` added to the current path (`charpath`). + fn type_path(&mut self, t: &TextObject) -> Res { + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + for g in &layout.glyphs { + let mut o = g.outline.clone(); + o.apply_affine(t.xf); + self.extend_path(o)?; + } + Ok(()) + } + + /// Glyph outline `o` added to the current path, unless the path would pass its most points. + fn extend_path(&mut self, o: vectorcraft_geom::BezPath) -> Res { + if self.g.path.elements().len().saturating_add(o.elements().len()) > super::graphics::MAX_PATH { + return Err(PsError::Limit("a path has too many points")); + } + self.g.path.extend(o); + Ok(()) + } + + /// `kshow` between character `i` of `s` (its codes) and the next: its procedure run with the + /// two codes and the current point at user point `pen` (PLRM 3rd ed., `kshow`: the procedure + /// may change the current point, as kerning does) → how far it moved the point (user space). /// Nothing for other show operators or after the last character. - fn kern(&mut self, spacing: &Spacing, s: &[u8], i: usize, pen: Point) -> Res { + fn kern>(&mut self, spacing: &Spacing, s: &[C], i: usize, pen: Point) -> Res { let (Spacing::Kern(p), Some(&code), Some(&next)) = (spacing, s.get(i), s.get(i + 1)) else { return Ok(Vec2::ZERO) }; self.g.cur = Some(self.xf() * pen); - self.push(Obj::Int(i64::from(code)))?; - self.push(Obj::Int(i64::from(next)))?; - self.call(p.clone())?; + self.push(Obj::Int(code.into()))?; + self.push(Obj::Int(next.into()))?; + // The procedure sees the font the file set, not the one type in a CIDFont only named is + // set in; a font it sets stays current. + let Some(outer) = self.kshow_font.take() else { + self.call(p.clone())?; + return Ok(self.user_point()? - pen); + }; + let inner = self.g.font.replace(outer.clone()); + let r = self.call(p.clone()); + // Once the procedure sets a font, it and the later procedures see that one. + if self.g.font.as_ref().is_some_and(|f| f.same(&outer)) { + self.g.font = inner; + self.kshow_font = Some(outer); + } + r?; Ok(self.user_point()? - pen) } @@ -757,15 +854,14 @@ impl Interp<'_> { if paint == Draw::Fill { return self.fill_path(outline, vectorcraft_geom::FillRule::NonZero); } - if self.g.path.elements().len().saturating_add(outline.elements().len()) > super::graphics::MAX_PATH { - return Err(PsError::Limit("a path has too many points")); - } - self.g.path.extend(outline); - Ok(()) + self.extend_path(outline) } /// `charpath`: the glyph outlines of `s` added to the path. fn char_path(&mut self, s: &[u8]) -> Res { + if let Some(named) = self.named_cid_type(s)? { + return self.show_named_cid(&named, &Spacing::Plain, Draw::Path); + } if let Some(glyphs) = self.composite_glyphs(s)? { for g in &glyphs { let at = self.user_point()?; @@ -791,10 +887,9 @@ impl Interp<'_> { let at = self.user_point()?; let (t, layout, adv) = self.set_type(s, at)?; for g in &layout.glyphs { - self.grow()?; let mut o = g.outline.clone(); o.apply_affine(t.xf); - self.g.path.extend(o); + self.extend_path(o)?; } self.g.cur = Some(self.xf() * (at + adv)); Ok(()) @@ -855,6 +950,16 @@ struct Mapped { vertical: bool, } +/// Type in a CIDFont only named, over a predefined CMap ([`Interp::named_cid_type`]): its codes +/// with their characters, the font it is set in, whether it runs down a column, and whether each +/// code moves the point one em (down a column, or in a CMap VectorCraft can't read). +struct NamedCid { + codes: Decoded, + font: DictRef, + vertical: bool, + ems: bool, +} + /// What drawing a glyph does with it. #[derive(Clone, Copy, PartialEq)] enum Draw { @@ -986,9 +1091,9 @@ impl Interp<'_> { /// of that name and its character collection's CID → Unicode CMap (a code it doesn't map /// stands for none), set as type in the font of its CIDFont's name, vertical over a vertical /// CMap (`WMode` 1). Control codes stand for no character. Type in a CMap VectorCraft can't - /// read is left out, with a warning. - fn named_cid_type(&mut self, s: &[u8]) -> Res> { - let Some((cmap, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) }; + /// read is left out, with a warning, each of its codes moving the point one em. + fn named_cid_type(&mut self, s: &[u8]) -> Res> { + let Some(cmap) = self.named_cid_parts().map(|(cmap, ..)| cmap) else { return Ok(None) }; // Each code is looked up in the CMap's mappings. self.spend(s.len() as u64)?; // The decoded codes and characters are held while the string is shown: checked against @@ -996,25 +1101,33 @@ impl Interp<'_> { let held = s.len().saturating_mul(DECODED_BYTES); self.alloc(held)?; self.unalloc(held); - let decoded = match self.predefined_codes(&cmap) { - Some(codes) => codes.codes(s), + let (decoded, read) = match self.predefined_codes(&cmap) { + Some(codes) => (codes.codes(s), true), None => { self.out.warn(UNREAD_CMAP); - Decoded::default() + (super::predefined_cmap::unread_codes(&cmap, s), false) } }; - let mut codes = Decoded::default(); + let Some(mut named) = self.named_cid_font()? else { return Ok(None) }; let mut chars = decoded.text.chars(); for &(code, n) in &decoded.codes { - codes.push(code, chars.by_ref().take(n).filter(|c| !c.is_control())); + named.codes.push(code, chars.by_ref().take(n).filter(|c| !c.is_control())); } + named.ems |= !read; + Ok(Some(named)) + } + + /// The font [`Self::named_cid_type`] sets type in, with no codes yet, when the current font is + /// a CID-keyed font over a predefined CMap the file doesn't define. + fn named_cid_font(&mut self) -> Res> { + let Some((_, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) }; // The composite font is copied under the CIDFont's name for every string: charged by // its size, so a huge font dictionary shown in a loop runs out of the budget. let entries = font.borrow().len(); self.spend(entries as u64)?; let mut named = font.borrow().clone(); named.insert(Key::name("FontName"), name); - Ok(Some((codes, DictRef::new(named), vertical))) + Ok(Some(NamedCid { codes: Decoded::default(), font: DictRef::new(named), vertical, ems: vertical })) } /// The CMap's name, the composite font, the CIDFont's name and the writing mode of @@ -1032,7 +1145,7 @@ impl Interp<'_> { return None; } let cmap_name = cmap.get(&Key::name("CMapName")).and_then(Obj::text)?.to_string(); - if super::cid::unicode_cmap(&cmap_name).is_none() && !super::predefined_cmap::known(&cmap_name) { + if !super::predefined_cmap::known(&cmap_name) { return None; } let wmode = f.get(&Key::name("WMode")).or_else(|| cmap.get(&Key::name("WMode"))).and_then(Obj::as_num); @@ -1047,7 +1160,7 @@ impl Interp<'_> { /// How strings in predefined CMap `cmap` decode (a CMap other than a Unicode CMap read once a /// file); `None` when VectorCraft can't read it. fn predefined_codes(&mut self, cmap: &str) -> Option> { - if let Some((form, _)) = super::cid::unicode_cmap(cmap) { + if let Some(form) = super::predefined_cmap::unicode_cmap(cmap) { return Some(Rc::new(super::predefined_cmap::CMapCodes::Unicode(form))); } if let Some((_, codes)) = self.predefined_cmaps.iter().find(|(n, _)| **n == *cmap) { @@ -1060,32 +1173,53 @@ impl Interp<'_> { codes } - /// The codes `codes` (with their characters) set as point type at the current point in font - /// `font` (see [`Self::named_cid_type`]), running down a column when `vertical`, which moves - /// past them, spaced as `spacing` says, as [`Self::show_codes`] shows them. `widthshow` and - /// `awidthshow` match a code by its value (font number 0 times 256 plus the code: PLRM 3rd ed., - /// `widthshow` in chapter 8); `kshow`'s procedure isn't run between them (a composite font's - /// codes aren't single bytes). Down a column each code moves the point one em, the default - /// vertical displacement of a CIDFont whose metrics the file doesn't give (PDF Reference 1.7, - /// §5.6.3, `DW2`). - fn show_named_cid(&mut self, codes: &Decoded, font: &DictRef, vertical: bool, spacing: &Spacing) -> Res { - let widths = vertical.then(|| vec![1.0; codes.codes.len()]); - let saved = self.g.font.replace(font.clone()); - let r = self.show_codes(codes, widths.as_deref(), spacing, vertical, &[]); + /// Type `named` (see [`Self::named_cid_type`]) set as point type at the current point in its + /// font, running down a column when it is vertical, which moves past it, spaced as `spacing` + /// says, as [`Self::show_codes`] shows codes. `widthshow` and `awidthshow` match a code by its + /// value (font number 0 times 256 plus the code: PLRM 3rd ed., `widthshow` in chapter 8), and + /// `kshow`'s procedure gets the values of the two codes it runs between. Down a column, and in + /// a CMap VectorCraft can't read, each code moves the point one em, the default displacement + /// of a CIDFont whose metrics the file doesn't give (PDF Reference 1.7, §5.6.3, `DW` and + /// `DW2`). With `draw` [`Draw::Path`] the outlines go on the current path (`charpath`). + fn show_named_cid(&mut self, named: &NamedCid, spacing: &Spacing, draw: Draw) -> Res { + let widths = named.ems.then(|| vec![1.0; named.codes.codes.len()]); + let kern: Vec = match spacing { + Spacing::Kern(_) => named.codes.codes.iter().map(|&(code, _)| code).collect(), + _ => Vec::new(), + }; + let saved = self.g.font.replace(named.font.clone()); + let before = std::mem::replace(&mut self.kshow_font, saved.clone()); + let r = self.show_codes(&named.codes, widths.as_deref(), spacing, named.vertical, &kern, draw); + self.kshow_font = before; + // A font the `kshow` procedure set stays current. + if self.g.font.as_ref().is_none_or(|f| f.same(&named.font)) { + self.g.font = saved; + } + r + } + + /// How far codes `codes` of type `named` move the current point (user space) when + /// [`Self::show_named_cid`] shows them with no extra spacing. + fn named_cid_width(&mut self, named: &NamedCid, codes: &Decoded) -> Res { + let saved = self.g.font.replace(named.font.clone()); + let r = self.named_cid_moved(codes, named.vertical, named.ems); self.g.font = saved; r } - /// How far the codes `codes` move the current point (user space) when [`Self::show_named_cid`] - /// shows them with no extra spacing. - fn named_cid_width(&mut self, codes: &Decoded, vertical: bool) -> Res { + /// [`Self::named_cid_width`] in the current font: codes `codes`, vertical when `vertical`, + /// each one em when `ems`. + fn named_cid_moved(&mut self, codes: &Decoded, vertical: bool, ems: bool) -> Res { let (_, f1) = self.font()?; - let moved = if vertical { - Point::new(0.0, -(codes.codes.len() as f64)) - } else { - let (t, layout) = self.set_placed_text(&codes.text, Point::ZERO, false)?; - let size = t.runs.first().map_or(1.0, |r| r.style.size); - Point::new(layout.glyphs.iter().map(|g| g.advance).sum::() / size, 0.0) + let n = codes.codes.len() as f64; + let moved = match (vertical, ems) { + (true, _) => Point::new(0.0, -n), + (false, true) => Point::new(n, 0.0), + (false, false) => { + let (t, layout) = self.set_placed_text(&codes.text, Point::ZERO, false)?; + let size = t.runs.first().map_or(1.0, |r| r.style.size); + Point::new(layout.glyphs.iter().map(|g| g.advance).sum::() / size, 0.0) + } }; Ok(f1 * moved - f1 * Point::ZERO) } @@ -1393,7 +1527,7 @@ const MISSING_CID_FONT: &str = "CID-keyed type in a font the file doesn't carry const DECODED_BYTES: usize = 64; /// The warning for type in a predefined CMap whose mappings VectorCraft doesn't have. -pub(super) const UNREAD_CMAP: &str = "Type in a CMap that couldn't be read was left out"; +pub(super) const UNREAD_CMAP: &str = "type in a CMap that couldn't be read was left out"; /// Most Type 42 fonts whose TrueType data is kept between glyphs. const MAX_TYPE42: usize = 16; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b99adf420e..8febc30aa2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -288,7 +288,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) | | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | -| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | +| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined CMap (`UniJIS-UCS2-H`, `90ms-RKSJ-H`, `GBK-EUC-H`, `KSCms-UHC-H`, `ETen-B5-H`), set as type in the font of the CIDFont's name, vertical over a `-V` CMap; a name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name, or the name has a hyphen and an installed face that covers Japanese has it; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open | diff --git a/docs/gaps.md b/docs/gaps.md index 83afa40942..7ecd8a385e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -195,7 +195,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Narrowed (#6165, 2026-11-09):** editable PDF import preserves explicit ActualText once, with empty/nested spans, authored order and glyph advances covered by `crates/pdf/src/tests/actual_text.rs`. One replacement uses the first placed glyph's line/style; exact redistribution across lines/styles and general mixed-direction layout remain. Input is bounded to depth 128 and 64 KiB encoded per accepted replacement. This does not close G9's broader `.ai`/EPS gaps or change its estimate. -- **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9. +- **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined CMaps (Unicode, Shift-JIS, EUC, GBK, Big Five, UHC) set as type, vertical over `-V` CMaps) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9. - **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged. - **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. diff --git a/docs/mcp.md b/docs/mcp.md index fa22c66414..ab2ae3c301 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6872,7 +6872,7 @@ dictionaries (the standard ones are values in `systemdict`; `dictstack`, `intern intervals share storage, resources (categories made from `Generic`, `resourceforall`), executable filters (`cvx exec` runs their data; `flushfile` skips it), axial and radial shadings and shading patterns (gradients), images and image masks (data in the file through ASCII85, hex, run-length, Flate, LZW or DCT filters, or from procedures), and type as -point type in the font the file names (embedded Type 1 and Type 42 fonts' characters and widths come from the font program they carry, and the show operators' spacing along the baseline is kept as tracking, while spacing that moves characters off it (`yshow`, `xyshow`) makes a text object per character; CID-keyed fonts draw the glyphs of the Type 0 and Type 2 CIDFonts the file carries, CIDFonts in a FontSet's Compact Font Format data included). In a file in Illustrator's own format +point type in the font the file names (embedded Type 1 and Type 42 fonts' characters and widths come from the font program they carry, and the show operators' spacing along the baseline is kept as tracking, while spacing that moves characters off it (`yshow`, `xyshow`) makes a text object per character; CID-keyed fonts draw the glyphs of the Type 0 and Type 2 CIDFonts the file carries, CIDFonts in a FontSet's Compact Font Format data included; a CID-keyed font whose CIDFont the file only names, over a predefined CMap such as `UniJIS-UCS2-H`, `90ms-RKSJ-H` or `GBK-EUC-H`, sets the characters its codes stand for as type in the font of the CIDFont's name, as vertical type over a `-V` CMap, and type in a predefined CMap that can't be read is left out with a warning). In a file in Illustrator's own format (Illustrator 3–8 `.ai` and their EPS, written with the prolog that defines its operators) the groups it writes (`u` … `U`) come in as groups, nested as they were; clips and groups nest at most 128 deep. The artboard is the `%%HiResBoundingBox` (else `%%BoundingBox`; a letter page without one). A program the interpreter can't run (an operator it doesn't know, From b4fbcc5672ef22be4ac60a48652812a1532c5779 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 06:58:57 -0800 Subject: [PATCH 69/79] Opacity masks: a strokes-only Transform effect scales a member's mask With Transform Objects off, a group's Transform effect scales the strokes and moves the patterns of a member's linked mask as it does the member's, instead of leaving the mask as it was. --- crates/effects/src/group/tests.rs | 18 ++++++++++++++++++ crates/effects/src/reshape.rs | 6 +++++- docs/target-app-parity.md | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index ddfe566fb8..a7fb05b2a6 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -1091,6 +1091,24 @@ fn a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike() { assert_eq!(width(&ch[0]), width(&ch[1]), "the image's mask"); } +/// With Transform Objects off and Scale Strokes & Effects on, a group's Transform effect scales the +/// strokes of a member's linked mask as it scales the member's, and moves neither. +#[test] +fn a_transform_of_strokes_only_scales_a_members_mask_strokes() { + let stroked = |id: u64| { + Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0)) + }; + let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(stroked(1), stroked(4), true))]); + let params = serde_json::json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let member = &art.children().unwrap()[0]; + let mask = &member.extras().mask.as_deref().unwrap().art; + assert_eq!(member.appearance.stroke().map(|s| s.width), Some(4.0), "the member's stroke"); + assert_eq!(mask.appearance.stroke().map(|s| s.width), Some(4.0), "the mask's stroke"); + assert_eq!(mask.geometric_bounds(), member.geometric_bounds()); +} + /// The art of a member group's mask is copied as it was before the first effect: a mask of one /// path takes a second Transform effect's copies path by path, as the path it masks does, though /// the first effect (with Scale Strokes & Effects) made it a group of copies. diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 6fa307d02e..483ac44d6e 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -947,7 +947,11 @@ impl Step<'_> { let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); *used = used.minus(before).plus(made); if moved { - moved_ids.insert(leaf.id); + // With Transform Objects off the path stays, and its mask takes the + // effect's stroke and pattern changes as its own art. + if options.objects { + moved_ids.insert(leaf.id); + } short |= counts(leaf.id) && copied(leaf) <= copies; } else { kept_ids.insert(leaf.id); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1bbf2c8a60..6b37d9e457 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1240,7 +1240,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group. Pattern editing mode and opacity mask editing exclude each other, by a rule of VectorCraft itself: in pattern editing mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Object › Pattern › Make and Edit Pattern are off. Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set. -- **Linked opacity masks follow geometry effects, the Free Transform distort and envelopes:** when masked art moves, a linked opacity mask moves with it and an unlinked one stays in place ([Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html)). Applying that rule when art is reshaped, as below, is VectorCraft's own rule. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, with the type and live objects in a mask's art outlined first. Each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too, and with Scale Strokes & Effects on, the mask's strokes scale as a path's mask's do. The linked masks in the art of a symbol instance in perspective are projected with that art. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object, in a group too, in the same undo step. An envelope bends only a linked mask. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, the mask would get fewer Transform copies, the mask's art holds an image or symbol instance and the effect is not Transform, a Warp effect would bend an image that has a linked mask, or the effect is a live-effect plug-in that paints its pieces), the art and its masks keep their shape under that effect. The copies of a masked path count the art of its mask against the budget. Existing documents draw differently in four cases: a geometry effect on a group whose members have linked masks moves those masks; an envelope no longer bends an unlinked mask; art with linked masks keeps its shape under an effect that its masks can't take; and a Transform effect can make fewer copies of a masked path near the budget (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::a_compound_paths_and_a_layers_members_masks_take_the_effects`, `group::tests::a_groups_effects_reshape_a_live_object_in_a_members_mask`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_groups_transform_moves_a_symbol_instance_members_linked_mask`, `group::tests::a_groups_transform_moves_a_linked_mask_whose_art_is_an_image`, `group::tests::art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp`, `group::tests::art_with_a_masked_image_keeps_its_shape_under_a_warp`, `group::tests::a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike`, `group::tests::perspective_projects_the_masks_in_a_symbols_art`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`, `group::tests::transform_copies_count_the_art_of_their_masks`, `group::tests::the_mask_of_a_masks_art_copied_whole_counts_against_the_budget`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `xform::distort_command_warps_the_linked_mask_of_a_path_in_a_group`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`, `plugins::a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is`). Scores and hours unchanged. +- **Linked opacity masks follow geometry effects, the Free Transform distort and envelopes:** when masked art moves, a linked opacity mask moves with it and an unlinked one stays in place ([Transparency and blending modes](https://helpx.adobe.com/illustrator/using/transparency-blending-modes-alt.html)). Applying that rule when art is reshaped, as below, is VectorCraft's own rule. When a group's or layer's geometry effects (Transform, Free Distort, the Warp effects and the others) reshape its members, the linked masks of the paths, compound paths and groups inside it take the same effects, nested and inverted masks included, with the type and live objects in a mask's art outlined first. Each Transform copy of a masked path carries its own mask. An image or symbol instance that a group's Transform effect moves carries its linked mask too, and with Scale Strokes & Effects on, the mask's strokes scale as a path's mask's do. With Transform Objects off, a member's mask takes the effect's stroke and pattern changes as the member does. The linked masks in the art of a symbol instance in perspective are projected with that art. A Free Transform tool distort drag (`object.distort`) warps a linked mask with its object, in a group too, in the same undo step. An envelope bends only a linked mask. When a mask can't take an effect as the art it masks does (a path in either would pass the geometry budget, the mask would get fewer Transform copies, the mask's art holds an image or symbol instance and the effect is not Transform, a Warp effect would bend an image that has a linked mask, or the effect is a live-effect plug-in that paints its pieces), the art and its masks keep their shape under that effect. The copies of a masked path count the art of its mask against the budget. Existing documents draw differently in four cases: a geometry effect on a group whose members have linked masks moves those masks; an envelope no longer bends an unlinked mask; art with linked masks keeps its shape under an effect that its masks can't take; and a Transform effect can make fewer copies of a masked path near the budget (effects `group::tests::a_groups_geometry_effects_reshape_a_members_linked_mask_with_it`, `group::tests::a_groups_geometry_effects_reshape_nested_and_inverted_masks`, `group::tests::a_compound_paths_and_a_layers_members_masks_take_the_effects`, `group::tests::a_groups_effects_reshape_a_live_object_in_a_members_mask`, `group::tests::each_transform_copy_of_a_masked_member_carries_its_mask`, `group::tests::a_groups_transform_moves_an_image_members_linked_mask`, `group::tests::a_groups_transform_moves_a_symbol_instance_members_linked_mask`, `group::tests::a_groups_transform_moves_a_linked_mask_whose_art_is_an_image`, `group::tests::art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp`, `group::tests::art_with_a_masked_image_keeps_its_shape_under_a_warp`, `group::tests::a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike`, `group::tests::a_transform_of_strokes_only_scales_a_members_mask_strokes`, `group::tests::perspective_projects_the_masks_in_a_symbols_art`, `group::tests::a_mask_whose_art_passes_the_budget_stays_whole`, `group::tests::art_whose_mask_would_take_fewer_copies_keeps_its_shape`, `group::tests::transform_copies_count_the_art_of_their_masks`, `group::tests::the_mask_of_a_masks_art_copied_whole_counts_against_the_budget`; engine `xform::distort_command_warps_a_linked_opacity_mask_with_its_object`, `xform::distort_command_warps_the_linked_mask_of_a_path_in_a_group`, `envelope_distort::an_envelope_bends_a_linked_opacity_mask_and_leaves_an_unlinked_one`, `plugins::a_painted_group_effect_leaves_art_with_a_linked_mask_as_it_is`). Scores and hours unchanged. - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. - **Image Trace, curve fitting tolerance:** the outline fitter keeps cutting a run while a point stays farther than the tolerance from its curve, so a run of fewer than six points or one cut 14 times no longer keeps a piece over the tolerance. Pieces already within the tolerance do not change, and noisy art can get more anchors. Tests: `logo::bezier::tests`, `logo::outline::tests`. Scores and hours unchanged. - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel. From cea0eb83585ba1ba30793d33b31998954f6fce01 Mon Sep 17 00:00:00 2001 From: Dan Green <danngreen1@gmail.com> Date: Mon, 9 Nov 2026 13:07:11 -0800 Subject: [PATCH 70/79] macOS: the canvas cursor stays after the pointer moves The AppKit cursor adapter set its cursor again only when NSCursor.currentCursor differed from it. But that is the app's last set cursor, not the one on screen: when the pointer moves, AppKit can put the system arrow back while currentCursor still reads as ours, so the adapter kept the arrow. Pressing V over the canvas showed the Selection tool's arrow, and the first move replaced it with the system pointer. While it owns the cursor, the adapter now sets it on every pass. That costs nothing when it is already showing. --- apps/vectorcraft/src/mac_cursor.rs | 11 ++++------- apps/vectorcraft/src/mac_cursor/tests.rs | 19 +++++-------------- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/apps/vectorcraft/src/mac_cursor.rs b/apps/vectorcraft/src/mac_cursor.rs index 85fea6c78a..69685a35b5 100644 --- a/apps/vectorcraft/src/mac_cursor.rs +++ b/apps/vectorcraft/src/mac_cursor.rs @@ -35,7 +35,6 @@ trait Backend { fn suspend(&mut self, window: &Self::Window); fn restore(&mut self, window: &Self::Window); fn build(&mut self, request: &Request) -> Option<Self::Cursor>; - fn current(&self) -> Option<Self::Cursor>; fn set(&mut self, cursor: &Self::Cursor); } @@ -73,9 +72,10 @@ impl<B: Backend> State<B> { && *key == request.key && *owned == window { - if self.backend.current().as_ref() != Some(cursor) { - self.backend.set(cursor); - } + // AppKit can show the system arrow when the pointer moves while + // `NSCursor.currentCursor` (the app's last set cursor) still reads as ours, so a + // comparison would keep the arrow. Setting the cursor again costs nothing. + self.backend.set(cursor); return; } self.release(); @@ -135,9 +135,6 @@ impl Backend for AppKit { fn build(&mut self, request: &Request) -> Option<Self::Cursor> { build(&request.bitmap) } - fn current(&self) -> Option<Self::Cursor> { - Some(NSCursor::currentCursor()) - } fn set(&mut self, cursor: &Self::Cursor) { cursor.set(); } diff --git a/apps/vectorcraft/src/mac_cursor/tests.rs b/apps/vectorcraft/src/mac_cursor/tests.rs index 648492c4b2..ad13a11336 100644 --- a/apps/vectorcraft/src/mac_cursor/tests.rs +++ b/apps/vectorcraft/src/mac_cursor/tests.rs @@ -6,7 +6,6 @@ use std::cell::Cell; struct Fake { window: Option<u8>, suspended: Vec<u8>, - current: Option<Key>, sets: usize, builds: usize, restores: Vec<u8>, @@ -27,17 +26,12 @@ impl Backend for Fake { fn restore(&mut self, w: &u8) { self.suspended.retain(|v| v != w); self.restores.push(*w); - self.current = None; } fn build(&mut self, r: &Request) -> Option<Key> { self.builds += 1; (!self.fail).then_some(r.key) } - fn current(&self) -> Option<Key> { - self.current - } - fn set(&mut self, cursor: &Key) { - self.current = Some(*cursor); + fn set(&mut self, _: &Key) { self.sets += 1; } } @@ -66,19 +60,16 @@ fn deliver(state: &mut State<Fake>, request: Option<Request>) { } #[test] -fn mac_cursor_ownership_deduplicates_reasserts_and_restores() { +fn mac_cursor_ownership_reasserts_every_pass_and_restores() { let req = request(1.0); let mut state = fake_state(); deliver(&mut state, Some(req.clone())); assert_eq!(state.backend.suspended, [1]); assert_eq!((state.backend.builds, state.backend.sets), (1, 1)); + // The pointer moves and AppKit shows the system arrow, but the app's current cursor + // still reads as ours. The next pass sets it again without rebuilding or re-suspending. deliver(&mut state, Some(req.clone())); - assert_eq!(state.backend.sets, 1); - // Simulate a platform fallback replacing the cursor after finalization. The next pass - // reasserts; this proves the protocol, not AppKit timing or absence of transient flicker. - state.backend.current = None; - deliver(&mut state, Some(req.clone())); - assert_eq!(state.backend.sets, 2); + assert_eq!((state.backend.builds, state.backend.sets, state.backend.suspended.len()), (1, 2, 1)); deliver(&mut state, None); assert_eq!(state.backend.restores, [1]); deliver(&mut state, Some(req.clone())); From 6e040ba4847736e2c3f81b82e2e43eb7b37b6803 Mon Sep 17 00:00:00 2001 From: Dan Green <danngreen1@gmail.com> Date: Wed, 11 Nov 2026 15:54:33 -0800 Subject: [PATCH 71/79] macOS cursor: progress docs for the cursor staying after the pointer moves The AppKit cursor delivery's evidence in docs/ui-parity.md and docs/target-app-parity.md says the host sets its cursor again on every pass while it owns it, so the tool's cursor stays when the pointer moves (#6362). Status lines and history updated; scores and estimates unchanged. --- docs/target-app-parity.md | 5 +++-- docs/ui-parity.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d42a4d988b..e2b3ca28f5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (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) > > Live Paint Bucket update (2026-11-12): one drag fills the faces crossed in the same group (#6373). > @@ -1585,7 +1585,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur anchor or with Alt held over a path (convert) and over another open path's end (join). The Eyedropper has an original pipette glyph with its tip at the hotspot; Use Precise Cursors restores the crosshair. The same glyph feeds OS bitmaps and painted cursors. DPI/hotspot and preference checks in `cursors.rs` and `tests/prefs.rs` cover #1269; - actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). A Precise Cursors command toggles the preference and can take a shortcut (#1663). On macOS the root document canvas now requests native AppKit delivery of the existing artwork, with aligned 1×/2× representations and a visible system fallback (#2292). `cursors/native_request` and `canvas` tests cover pixel/hotspot alignment, preferences and fallback routing; `mac_cursor` tests cover real AppKit image construction and the ownership protocol against a fake backend. The host releases on native-menu tracking and loss of eligibility; detached document viewports retain the visible system fallback. The pre-platform-output rectangle strategy, real focus/menu/handback behavior, mixed displays, Spotlight and native full-screen Spaces remain unverified; #2020 and #2292 are not declared closed. + actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). A Precise Cursors command toggles the preference and can take a shortcut (#1663). On macOS the root document canvas now requests native AppKit delivery of the existing artwork, with aligned 1×/2× representations and a visible system fallback (#2292). While the host owns the cursor it sets it again on every pass instead of comparing it with `NSCursor.currentCursor`, which is the app's last set cursor rather than the one on screen, so the tool's cursor no longer gives way to the system arrow when the pointer moves (#6362). `cursors/native_request` and `canvas` tests cover pixel/hotspot alignment, preferences and fallback routing; `mac_cursor` tests cover real AppKit image construction and the ownership protocol against a fake backend. The host releases on native-menu tracking and loss of eligibility; detached document viewports retain the visible system fallback. The pre-platform-output rectangle strategy, real focus/menu/handback behavior, mixed displays, Spotlight and native full-screen Spaces remain unverified; #2020 and #2292 are not declared closed. In a rotated view the resize and rotate cursors turn with the view, to the nearest 45°. - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. Print-size images (more than 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image with a side past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as it does on macOS and Linux (#1502). Hard-edge rendering, including the internal hard-edge frame of Art Optimized supersampled raster exports, draws a knockout element with one opaque solid fill once instead of erasing and adding it through separate layers. Analytical antialiasing keeps the existing compositor and rounding. Synthetic path/compound-hole tests compare exact pixels against the general knockout path across precision, supersampling, translucent parent groups and backdrop blending; translucent fills retain their knockout behavior. @@ -2077,6 +2077,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-11-12 | minor | Type: kerning at the Type tool's caret at the start of a line (the text's, a paragraph's, or after a forced line break) sets the space before the character after it (`kerningBefore`), which moves it and the rest of its line; it did nothing at the text's start and kerned the invisible line break at a paragraph's (#5111). Scores and estimates unchanged. | | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does (Smart Guides, Snap to Point and the ruler guides, pixels, the grid); evidence updated; scores unchanged. | +| 2026-11-11 | minor | Interim macOS canvas cursor delivery (#2292): the tool's cursor stays after the pointer moves (#6362); tool cursors evidence updated; scores and hours unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Envelopes: with Distort Appearance, type's strokes (characters' and appearance) paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them; paths' strokes, geometry effects, gradients and patterns bend as before; also in an envelope inside an envelope; opacity masks bend as other art; Expand keeps type's strokes as strokes; the visual bounds of envelopes, blends and repeats and the reach of symbol art count type's character strokes as type's own bounds do. Changes how VectorCraft's own envelopes of stroked type draw. Advanced art evidence and `.ai` import evidence updated (#6644); scores and estimates unchanged. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index dbe69b7603..f5e985e01d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag 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: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: 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; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; 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; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag 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: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: 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; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; 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; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -332,7 +332,7 @@ tools, never checked side by side) isn't captured row by row. | Tool double-click opens its options | done | | | Per-platform mapping (Cmd ↔ Ctrl, Option ↔ Alt) | done | | | Eyedropper cursor; Use Precise Cursors | partial | original pipette at the sampling tip, distinct from the precise crosshair at five DPI scales (#1269); OS cursor display unverified; while Caps Lock is on, the tools that Use Precise Cursors changes show the crosshair, and the saved preference does not change (the desktop app reads the lock state on macOS, Windows and Linux X11, not on native Wayland; `view.capsLock` reads or sets it); a Use Precise Cursors command toggles the preference and can take a shortcut (#1663); cursors are painted into the window on Wayland (#1652, not run on Linux) | -| macOS canvas cursor delivery (#2292) | partial | The UI prepares the existing glyphs as aligned 1×/2× RGBA requests; the desktop host builds AppKit cursors and owns cursor rectangles for the root document window. Detached document viewports and requests without a usable native bitmap retain the visible system fallback; precise-cursor and UI-scale preferences retained. Headless regressions cover routing, pixels/hotspots and lifecycle; native focus/menu/handback timing, mixed displays, Spotlight and full-screen Spaces remain unverified. Wayland still paints its glyphs. | +| macOS canvas cursor delivery (#2292) | partial | The UI prepares the existing glyphs as aligned 1×/2× RGBA requests; the desktop host builds AppKit cursors and owns cursor rectangles for the root document window. While it owns the cursor, the host sets it again on every pass, so the tool's cursor stays when the pointer moves (#6362). Detached document viewports and requests without a usable native bitmap retain the visible system fallback; precise-cursor and UI-scale preferences retained. Headless regressions cover routing, pixels/hotspots and lifecycle; native focus/menu/handback timing, mixed displays, Spotlight and full-screen Spaces remain unverified. Wayland still paints its glyphs. | | Every tool's modifiers checked against the shortcut reference | missing | this is the fidelity pass | ## Keyboard shortcuts @@ -523,6 +523,7 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | 2026-11-12 | minor | Remove Background for a selected image in the Contextual Task Bar, Properties › Quick Actions and the Control bar, which runs at once with its defaults. Scores and estimates unchanged. | | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Ruler origin: the corner drag snaps as a drawn point does, onto the art's and the artboards' anchors, centres, edges and paths, and with Snap to Point along the ruler guides; scores and estimates unchanged | +| 2026-11-11 | minor | macOS canvas cursor delivery (#2292): while the host owns the cursor it sets it again on every pass, so the tool's cursor stays after the pointer moves (#6362); `mac_cursor` ownership test updated; scores and estimates unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | From 8a6361b45f89e6832c49b20b2a5cac903c92a0ab Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 07:04:06 -0800 Subject: [PATCH 72/79] Global Edit: selecting one object highlights the similar objects before the start With one object that Start Global Edit accepts selected with the Selection tool, the canvas now highlights the other objects a start would take with the current Global Edit Options, before Global Edit starts. The outlines are kept per document revision and options, and none are shown during a drag. Docs: docs/mcp.md (Global Edit), docs/gaps.md (G5). --- crates/engine/src/cmd/globaledit.rs | 52 +++++++++++++++++++++++---- crates/engine/src/doc_state.rs | 4 +++ crates/engine/src/tests/globaledit.rs | 46 +++++++++++++++++++++++- crates/engine/src/tooling.rs | 7 ++-- docs/gaps.md | 5 +-- docs/mcp.md | 4 ++- 6 files changed, 105 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index 4a42f03d4f..98f53b102e 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -20,6 +20,9 @@ //! and they lie the same way relative to each other: the group as a whole is moved or scaled. //! With Appearance on, each of those objects must have the same fill and stroke too; with Size, //! the groups the same width and height. +//! - Selecting one of the repeated objects with the Selection tool highlights the similar objects +//! at once, before Global Edit starts. Here they are the objects Start Global Edit would take +//! with the current Global Edit Options ([`similar`]). //! - While Global Edit is on, an edit made to one of the found objects is made to all of them, //! each in proportion to its own size: halving the height of a 100 pt object halves a 20 pt //! match to 10 pt. Stop Global Edit (or Escape) ends it. @@ -780,18 +783,55 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se /// The outlines of the members of the active document's Global Edit that are not selected, hold /// no selected object and sit inside no selected object, in their layer's colour: the canvas -/// highlights them while Global Edit is on. +/// highlights them while Global Edit is on. While it is off, with one supported object selected, +/// they are the outlines of the objects Start Global Edit would take with the Global Edit Options +/// ([`similar`]). pub fn highlights(s: &Session) -> Outlines { let Some(st) = s.active() else { return vec![] }; - let Some(ge) = &st.global_edit else { return vec![] }; + let Some(ge) = &st.global_edit else { return similar(s) }; if let Some((rev, out)) = &*ge.outlines.borrow() && *rev == st.revision { return out.clone(); } + let out = outlines(st, &ge.members); + *ge.outlines.borrow_mut() = Some((st.revision, out.clone())); + out +} + +/// [`similar`]'s outlines as of a document revision and the options they were found with. +pub type SimilarCache = std::cell::RefCell<Option<(u64, Options, Outlines)>>; + +/// Before Global Edit starts: with one supported object selected, the outlines of the other +/// objects Start Global Edit would take now with the session's Global Edit Options, so the canvas +/// shows them as soon as the object is selected. None while an interaction (a drag) is in +/// progress, or when fewer than two objects would be taken. Kept per document revision and +/// options, as finding them walks the whole document. +pub fn similar(s: &Session) -> Outlines { + let Some(st) = s.active() else { return vec![] }; + if st.global_edit.is_some() || st.interaction.is_some() || st.selection.objects.len() != 1 { + return vec![]; + } + let o = &s.global_edit_options; + if let Some((rev, opts, out)) = &*st.similar_outlines.borrow() + && *rev == st.revision + && opts == o + { + return out.clone(); + } + let out = match matches(s, o) { + Ok(m) if m.len() >= 2 => outlines(st, &m), + _ => vec![], + }; + *st.similar_outlines.borrow_mut() = Some((st.revision, o.clone(), out.clone())); + out +} + +/// The outlines of `members` that are not selected, hold no selected object and sit inside no +/// selected object, in their layer's color. +fn outlines(st: &crate::DocState, members: &[(NodeId, Affine)]) -> Outlines { let symbols = st.doc.symbol_boxes(); - let out: Vec<_> = ge - .members + members .iter() .filter_map(|(id, _)| { let n = st.doc.node(*id).filter(|n| !holds(n, &st.selection.objects))?; @@ -804,9 +844,7 @@ pub fn highlights(s: &Session) -> Outlines { }; Some((path, st.doc.layer_color(*id))) }) - .collect(); - *ge.outlines.borrow_mut() = Some((st.revision, out.clone())); - out + .collect() } #[cfg(test)] diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 08b8a2f294..0ef7555e02 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -34,6 +34,9 @@ pub struct DocState { /// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape /// ([`cmd::globaledit`]). Editing state: not saved, not undoable. pub global_edit: Option<cmd::globaledit::GlobalEdit>, + /// The objects Start Global Edit would take, highlighted before it starts + /// ([`cmd::globaledit::similar`]), as of a revision. Not saved, not undoable. + pub(crate) similar_outlines: cmd::globaledit::SimilarCache, /// What leaving symbol editing mode goes back to ([`cmd::symboledit`]). pub(crate) symbol_edit_return: cmd::symboledit::Return, pub interaction: Option<Interaction>, @@ -302,6 +305,7 @@ impl DocState { variables_highlight: None, isolation: None, global_edit: None, + similar_outlines: Default::default(), symbol_edit_return: Default::default(), interaction: None, undo_group: None, diff --git a/crates/engine/src/tests/globaledit.rs b/crates/engine/src/tests/globaledit.rs index 60bf81a060..ac5a494dc7 100644 --- a/crates/engine/src/tests/globaledit.rs +++ b/crates/engine/src/tests/globaledit.rs @@ -92,7 +92,50 @@ fn start_global_edit_takes_the_objects_of_the_same_shape_at_any_place_and_size() assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2); let r = s.execute("select.globalEdit", &json!({})).unwrap(); assert_eq!(r["active"], false, "the second run is Stop Global Edit"); - assert!(s.doc().unwrap().global_edit.is_none() && crate::cmd::globaledit::highlights(&s).is_empty()); + assert!(s.doc().unwrap().global_edit.is_none()); + assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2, "with the square still selected, the objects it would take are highlighted again"); +} + +#[test] +fn selecting_one_object_highlights_the_similar_objects_before_global_edit_starts() { + use crate::cmd::globaledit::highlights; + let mut s = session(); + let [a, b, _, wide, _] = squares(&mut s); + assert!(highlights(&s).is_empty(), "nothing selected"); + sel(&mut s, &[a]); + assert_eq!(members(&s), None, "Global Edit has not started"); + assert_eq!(highlights(&s).len(), 2, "the copy and the smaller square"); + s.execute("select.globalEditOptions", &json!({"size": true})).unwrap(); + assert_eq!(highlights(&s).len(), 1, "the options narrow them: the copy only"); + s.execute("select.globalEditOptions", &json!({"size": false})).unwrap(); + assert_eq!(highlights(&s).len(), 2); + sel(&mut s, &[b]); + s.execute("edit.clear", &json!({})).unwrap(); + sel(&mut s, &[a]); + assert_eq!(highlights(&s).len(), 1, "a deleted copy is no longer highlighted"); + sel(&mut s, &[wide]); + assert!(highlights(&s).is_empty(), "the rectangle has no similar object"); + sel(&mut s, &[a, wide]); + assert!(highlights(&s).is_empty(), "two objects selected"); +} + +#[test] +fn the_canvas_highlights_similar_objects_before_the_start_with_the_selection_tool_only() { + let mut s = session(); + let [a, b, ..] = squares(&mut s); + let outline = node(&s, b).stroke_path().unwrap(); + let shown = |s: &mut Session| { + s.overlays(ViewInfo::default()) + .iter() + .any(|o| matches!(o, vectorcraft_tools::Overlay::Path { path, width, .. } if *path == outline && *width == 1.0)) + }; + sel(&mut s, &[a]); + s.select_tool("selection", ViewInfo::default()).unwrap(); + assert!(shown(&mut s), "the copy is highlighted with the Selection tool"); + s.select_tool("directSelection", ViewInfo::default()).unwrap(); + assert!(!shown(&mut s), "not with another tool before Global Edit starts"); + s.execute("select.globalEdit", &json!({})).unwrap(); + assert!(shown(&mut s), "while Global Edit is on, with any tool"); } #[test] @@ -506,6 +549,7 @@ fn leaving_out_all_but_one_ends_global_edit_and_the_first_object_stays() { let r = s.execute("select.globalEditExclude", &json!({"id": b.0})).unwrap(); assert_eq!(r, json!({"active": false, "count": 0, "ids": []})); assert_eq!(members(&s), None, "one object left: Global Edit ends"); + sel(&mut s, &[wide]); assert!(crate::cmd::globaledit::highlights(&s).is_empty()); } diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 9bc450f937..4f8188dbe7 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1142,8 +1142,11 @@ impl Session { let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position }); v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays_in(&d.doc, 1.0 / view.zoom.max(1e-9), place)); } - // Global Edit: the objects edited with the selected one are highlighted. - for (path, color) in crate::cmd::globaledit::highlights(self) { + // Global Edit: the objects edited with the selected one are highlighted; before it starts, + // with the Selection tool, the objects it would take. + let ge_on = self.active().is_some_and(|d| d.global_edit.is_some()); + let shown = if ge_on || self.tool.id() == "selection" { crate::cmd::globaledit::highlights(self) } else { vec![] }; + for (path, color) in shown { v.push(Overlay::Path { path, color, width: 1.0, dashed: false }); } if let Some((cmd, p)) = deferred_preview { diff --git a/docs/gaps.md b/docs/gaps.md index 83afa40942..bc1c526e97 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -610,6 +610,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi them in proportion, as one undo step ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)); on a group (#4939), the groups like it (the same objects at the same places, moved or scaled) take an edit made to an object inside it at the object in the same place ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); + with one such object or group selected with the Selection tool, the objects Start Global Edit would take are + highlighted on the canvas before it starts (`selecting_one_object_highlights_the_similar_objects_before_global_edit_starts`); dynamic symbols (#4938): Symbol Options › Symbol Type (Dynamic Symbol / Static Symbol; `type` on `symbol.new` / `symbol.options` / `symbol.list`), an instance's own fill, stroke, opacity and blend for an object of a dynamic symbol's art (`symbol.instance.setAppearance`, by child-index path), drawn and exported @@ -654,8 +656,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi `graphicStyle.addFromLibrary`). - **Missing:** more built-in brush libraries beyond the seven shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; - Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the - start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). + Global Edit of groups holding type or images, and rotated copies as matches; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). - **Estimate:** 5–15 h. ## G3 3D and Materials diff --git a/docs/mcp.md b/docs/mcp.md index fa22c66414..87f91994a5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2399,7 +2399,9 @@ each of the others, whose hidden or locked objects are left as they are; when th other group takes its objects at that level, keeping its own ids in order. Whenever the document is put back (Undo, Redo, a cancelled drag or scrub), the matches it no longer has leave the Global Edit, which ends when fewer than two are left. An edit that would take any match past the canvas fails with the coordinates-out-of-range error and changes -nothing. Nothing of it is saved. +nothing. Nothing of it is saved. Before Global Edit starts, while one object that `select.globalEdit` accepts is +selected with the Selection tool, the canvas highlights the other objects a start would take with the current Global +Edit Options (none while a drag is in progress, or when there are none). `select.globalEditExclude {id}` leaves the match `id` out of the Global Edit, as a Shift-click on a highlighted match (or on an object inside it) with the Selection tool does: it is no longer highlighted, and later edits leave it as it From 9b7a16514b4d486bf935659eaa67f8210f1609d5 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:37:52 +0430 Subject: [PATCH 73/79] docs: add M7 Glyphs alternates entry in roadmap.md --- docs/roadmap.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 51b0eb31bb..d7df0b6b52 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -146,7 +146,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); 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 | 3–6 | -| 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, 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 | +| 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 `<pattern>`/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, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, seven generated libraries including Bristle Brushes, 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 | @@ -190,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | M7: Glyphs panel applies feature-valued alternates and unencoded glyphs while keeping Arabic joining (#5514). Scores and estimates unchanged. | | 2026-11-12 | minor | M12: Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged | From 108b3d9912864605e8d21b4a0b93dd736e581b2a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 05:20:06 -0800 Subject: [PATCH 74/79] Image Trace: band borders in smooth areas follow the image's contours In a gradient or a shaded area the palette colors on either side of a band border differ by more than the image changes across it, so pixel noise decided where the border ran, and it was smoothed over 0.5 px like a real edge: borders wobbled, kept stair steps and took many anchors. Each crack between two colors now records how much of the step between them the image makes next to it and a few pixels further out, how much the image changes there along the line between the two colors and in any direction, and how much it changes within each side (RGB distances, 0 to 255 a channel). A piece of outline is the contour of a smooth area when its cracks show less than half of the step; either more of it further out, a change along the line under 12, or no opaque pixels further out; some change within the sides, at least 1/32 of the change along the line; and a whole change under twice the change along the line, or under 24. Such a piece is smoothed over 4 px (a closed one over at most an eighth of its smaller width or height), keeps no corners, is fitted to at least 0.5 px and is checked against its smoothed points. The values are the same from either side of the crack. Grayscale reads each pixel as its gray, for this rule and for every edge's sub-pixel shift, which compared the pixels' colors with gray palette entries. Color traces of clean flat-color art and Black and White traces are unchanged. Specks rise by up to 0.8 per image on gradient and photo inputs, and Auto Color with Shapes leaves a 0.3 px gap there. Docs: docs/target-app-parity.md, docs/image-trace-quality.md. --- crates/trace/src/fit.rs | 45 ++- crates/trace/src/fit/junction_return_tests.rs | 7 +- crates/trace/src/layers.rs | 276 +++++++++++++++++- crates/trace/src/logo.rs | 2 +- crates/trace/src/logo/geom.rs | 9 +- crates/trace/src/logo/outline.rs | 14 +- crates/trace/src/subpixel.rs | 158 +++++++++- crates/trace/src/tests.rs | 86 ++++++ docs/image-trace-quality.md | 30 ++ docs/target-app-parity.md | 1 + 10 files changed, 587 insertions(+), 41 deletions(-) diff --git a/crates/trace/src/fit.rs b/crates/trace/src/fit.rs index 39fe3c974c..1ae81b14e8 100644 --- a/crates/trace/src/fit.rs +++ b/crates/trace/src/fit.rs @@ -4,7 +4,10 @@ use kurbo::ParamCurveArea; use vectorcraft_geom::{Anchor, AnchorKind, Point, SubPath}; use crate::contour::Loop; -use crate::logo::{Cubic, Stats, Style, all_within, fit_cubics, fit_open, fit_outline, resample_closed, resample_open, sample_cubics, tangent}; +use crate::logo::{ + Cubic, Stats, Style, all_within, fit_cubics, fit_open, fit_outline, resample_closed, resample_open, sample_cubics, smooth_closed, smooth_pinned, + tangent, +}; use crate::subpixel::{self, Crack}; #[cfg(test)] @@ -140,7 +143,9 @@ pub(crate) fn fit_closed(pts: &[Point], style: &Style) -> Option<Vec<Cubic>> { let mut segs = fit_outline(&pts, style, &mut stats)?; // A fit that strayed was replaced by a plain one that nothing has checked: one that strays too // (a wild handle on a tiny or ragged loop) gives way to the outline's own points. - if stats.reverted > 0 && !all_within(&sample_cubics(&segs, 0.25), &resample_closed(&pts, 0.5).map_or_else(|| pts.clone(), |(p, _)| p), 1.5) { + let followed = + resample_closed(&pts, 0.5).map_or_else(|| pts.clone(), |(p, step)| if style.contour { smooth_closed(&p, style.sigma / step) } else { p }); + if stats.reverted > 0 && !all_within(&sample_cubics(&segs, 0.25), &followed, 1.5) { segs = polygon(&pts, style.tol); } // A coarse fit of a tiny loop can be a single cubic. A closed editable @@ -250,8 +255,11 @@ pub(crate) fn fit_piece(pts: &[Point], style: &Style, fixed_ends: bool) -> Vec<C pts.reverse(); } let mut segs = fit_open(&pts, style, &mut Stats::default()); - // Nothing else checks this fit: one that strays gives way to the piece's own points. - if !all_within(&sample_cubics(&segs, 0.25), &resample_open(&pts, 0.5).map_or_else(|| pts.clone(), |(p, _)| p), 1.5) { + // Nothing else checks this fit: one that strays from the points it follows (the contour of a + // smooth area follows them smoothed) gives way to the piece's own points. + let followed = + resample_open(&pts, 0.5).map_or_else(|| pts.clone(), |(p, step)| if style.contour { smooth_pinned(&p, style.sigma / step) } else { p }); + if !all_within(&sample_cubics(&segs, 0.25), &followed, 1.5) { let mut keep = vec![false; n]; rdp(&pts, 0, n - 1, style.tol, &mut keep); keep[0] = true; @@ -288,16 +296,26 @@ pub(crate) fn fit_piece(pts: &[Point], style: &Style, fixed_ends: bool) -> Vec<C if flip { reversed(segs) } else { segs } } +/// How far (px) the contour of a smooth area is smoothed: bumps shorter than about 8 px along it +/// are noise. +const CONTOUR_SIGMA: f64 = 4.0; + +/// The least fitting tolerance (px) for the contour of a smooth area. +const CONTOUR_TOL: f64 = 0.5; + /// Fit one loop of a `w × h` image into a closed Bézier subpath (pixel coordinates): its points /// are placed to a fraction of a pixel ([`subpixel::outline`], which explains `across`) and fitted /// as Flat Logo mode fits its outlines (corners restored, straight runs as lines, the rest as few /// curves as `style.tol` allows), piece by piece between its junctions, so that the regions on -/// either side of an edge get the same curve for it. +/// either side of an edge get the same curve for it. A piece that is the contour of a smooth area +/// ([`subpixel::low_contrast`]) has no corners and is smoothed by [`CONTOUR_SIGMA`] (a closed one +/// by at most an eighth of its smaller width or height, so that it keeps its size) and fitted to at +/// least [`CONTOUR_TOL`]. pub(crate) fn fit_loop( l: &Loop, w: usize, h: usize, - across: &dyn Fn(Crack) -> (f64, u32), + across: &dyn Fn(Crack) -> (f64, u32, [f64; 5]), junction_at: &dyn Fn(i32, i32) -> Option<Point>, style: &Style, ) -> Option<(SubPath, bool)> { @@ -312,6 +330,21 @@ pub(crate) fn fit_loop( style.circle_tol *= 1.8; style.sigma = 0.9; } + if piece.contour { + style.sigma = CONTOUR_SIGMA; + if piece.closed { + // (The box is the same whichever way the loop runs.) + let (x0, x1, y0, y1) = + piece.pts.iter().fold((f64::MAX, f64::MIN, f64::MAX, f64::MIN), |b, p| (b.0.min(p.x), b.1.max(p.x), b.2.min(p.y), b.3.max(p.y))); + // Smoothing a loop of radius r by s draws it in by about s²/2r: an eighth of its + // width keeps that to about 3%. + style.sigma = style.sigma.min((x1 - x0).min(y1 - y0) / 8.0); + } + style.contour = true; + style.corner_deg = f64::INFINITY; + style.restore = None; + style.tol = style.tol.max(CONTOUR_TOL); + } if piece.closed { segs = fit_closed(&piece.pts, &style)?; } else { diff --git a/crates/trace/src/fit/junction_return_tests.rs b/crates/trace/src/fit/junction_return_tests.rs index fea2ea7dcc..0618a0969b 100644 --- a/crates/trace/src/fit/junction_return_tests.rs +++ b/crates/trace/src/fit/junction_return_tests.rs @@ -35,7 +35,8 @@ fn returning_piece_keeps_its_junction_and_interior() { let raw = Loop { pts: pts.clone(), area2: 2 * i64::from(width) }; let w = offset.0 as usize + 20; let h = offset.1 as usize + 20; - let (path, _) = fit_loop(&raw, w, h, &|_| (0., 0), &at, &style).expect("a positive-area returning loop must be editable"); + let (path, _) = + fit_loop(&raw, w, h, &|_| (0., 0, subpixel::EDGE_STEP), &at, &style).expect("a positive-area returning loop must be editable"); assert_eq!(path.anchors[0].p, Point::new(junction.0 as f64, junction.1 as f64)); let mut local = path.clone(); let shift = Point::new(offset.0 as f64, offset.1 as f64).to_vec2(); @@ -46,7 +47,7 @@ fn returning_piece_keeps_its_junction_and_interior() { } assert!(local.area().abs() > 0.9 * width as f64, "{width}, {turns}, {offset:?}"); let reverse = Loop { pts: pts.into_iter().rev().collect(), area2: -raw.area2 }; - let (back, _) = fit_loop(&reverse, w, h, &|_| (0., 0), &at, &style).unwrap(); + let (back, _) = fit_loop(&reverse, w, h, &|_| (0., 0, subpixel::EDGE_STEP), &at, &style).unwrap(); let mut expected = path; expected.reverse(); let start = expected.anchors.iter().position(|a| a.p == back.anchors[0].p).unwrap(); @@ -64,7 +65,7 @@ fn returning_piece_does_not_promote_a_zero_area_chain() { let style = crate::TraceParams { corners: 0., paths: 0., ..crate::TraceParams::default() }.outline_style(); // All four subpixel corners coincide; the raw pixel has area but its // positioned boundary does not. The fallback must not invent ink. - assert!(fit_loop(&raw, 4, 4, &|_| (-0.5, 0), &junction, &style).is_none()); + assert!(fit_loop(&raw, 4, 4, &|_| (-0.5, 0, subpixel::EDGE_STEP), &junction, &style).is_none()); } #[test] diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs index 5050f4a2dd..e1a0539a9d 100644 --- a/crates/trace/src/layers.rs +++ b/crates/trace/src/layers.rs @@ -137,14 +137,7 @@ impl Layers<'_> { let tone = |l: u16| [if l == 1 { black } else { white }; 3]; (tone(la), tone(lb)) } else { - // A side's own colour: its palette entry's, or its gradient's colour here. - let own = |l: u16, k: i32| { - let flat = self.q.palette.get(l as usize)?.map(f64::from); - let Some(g) = self.gradients.get(l as usize).and_then(Option::as_ref) else { return Some(flat) }; - let i = c.at(k)?; - Some(g.color_at(vectorcraft_geom::Point::new((i % self.w) as f64 + 0.5, (i / self.w) as f64 + 0.5))) - }; - let (Some(ca), Some(cb)) = (own(la, 0), own(lb, 1)) else { return 0.0 }; + let (Some(ca), Some(cb)) = (self.own_color(la, c, 0), self.own_color(lb, c, 1)) else { return 0.0 }; (ca, cb) }; let d = [0, 1, 2].map(|k| cb[k] - ca[k]); @@ -152,10 +145,12 @@ impl Layers<'_> { if len2 <= 0.0 { return 0.0; } + let gray = self.params.mode == Mode::Grayscale; // How much of the inside tone (0 to 1) the pixel `k` steps out has; none off the image. + // Grayscale reads each pixel as the gray it traces it as. let inside = |k: i32| { - let Some(p) = c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 3)) else { return 0.0 }; - let p = [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]; + let Some(&[r, g, b, a]) = c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 4)) else { return 0.0 }; + let p = if gray { [f64::from(super::quantize::luma([r, g, b, a])); 3] } else { [f64::from(r), f64::from(g), f64::from(b)] }; let p = if bw { [(p[0] * 299.0 + p[1] * 587.0 + p[2] * 114.0) / 1000.0; 3] } else { p }; 1.0 - ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::<f64>() / len2).clamp(0.0, 1.0) }; @@ -205,6 +200,85 @@ impl Layers<'_> { shift } + /// The color of label `l` at the pixel `k` steps out from the inside pixel of crack `c`: its + /// palette entry's, or its gradient's color there. + fn own_color(&self, l: u16, c: Crack, k: i32) -> Option<[f64; 3]> { + let flat = self.q.palette.get(l as usize)?.map(f64::from); + let Some(g) = self.gradients.get(l as usize).and_then(Option::as_ref) else { return Some(flat) }; + let i = c.at(k)?; + Some(g.color_at(vectorcraft_geom::Point::new((i % self.w) as f64 + 0.5, (i / self.w) as f64 + 0.5))) + } + + /// What the image does across a crack, for `subpixel::low_contrast`. Distances between colors + /// are RGB distances (0 to 255 a channel), and Grayscale reads each pixel as the gray it traces + /// it as. In order: + /// + /// - the share of the step from the inside color to the outside one that the image makes near + /// the crack, measured along the line between the two colors: from the mean of the two pixels + /// behind the inside pixel to the mean of the two beyond the outside one, each side only as + /// far as its own label goes; + /// - the share further out, from the next two pixels on one side to the next two on the other, + /// whatever their labels (not a number where a side has none); + /// - the near change along that line, as a distance; + /// - the whole near change, in any direction, as a distance; + /// - how much the image changes within each side: the distance between the two pixels behind + /// the inside pixel and between the two beyond the outside one, where both have the side's + /// label, averaged (not a number where neither side has two such pixels). + /// + /// Where a side has no pixel near the crack, and in Black and White, whose two tones are not the + /// colors of its areas, it is [`subpixel::EDGE_STEP`]. Exactly the same read from either side. + fn step(&self, c: Crack) -> [f64; 5] { + use super::subpixel::EDGE_STEP; + let label = |k: i32| c.at(k).and_then(|i| self.q.labels.get(i).copied()).filter(|&l| l != TRANSPARENT); + let (Some(la), Some(lb)) = (label(0), label(1)) else { return EDGE_STEP }; + if la == lb || self.params.mode == Mode::BlackAndWhite { + return EDGE_STEP; + } + let (Some(ca), Some(cb)) = (self.own_color(la, c, 0), self.own_color(lb, c, 1)) else { return EDGE_STEP }; + let d = [0, 1, 2].map(|k| cb[k] - ca[k]); + let len2: f64 = d.iter().map(|v| v * v).sum(); + if len2 <= 0.0 { + return EDGE_STEP; + } + let gray = self.params.mode == Mode::Grayscale; + // The pixel `k`, while it is in the image and not transparent (and has label `l`, if one is + // given). + let pixel = |k: i32, l: Option<u16>| { + label(k).filter(|&x| l.is_none_or(|l| x == l))?; + let &[r, g, b, a] = c.at(k).and_then(|i| self.img.rgba.get(i * 4..i * 4 + 4))? else { return None }; + Some(if gray { [f64::from(super::quantize::luma([r, g, b, a])); 3] } else { [f64::from(r), f64::from(g), f64::from(b)] }) + }; + // The mean color of the pixels `ks` up to the first one `pixel` refuses. + let mean = |ks: [i32; 2], l: Option<u16>| { + let (mut sum, mut n) = ([0.0; 3], 0.0); + for p in ks.iter().map_while(|&k| pixel(k, l)) { + for (s, v) in sum.iter_mut().zip(p) { + *s += v; + } + n += 1.0; + } + (n > 0.0).then(|| sum.map(|s| s / n)) + }; + let distance = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (b[k] - a[k]).powi(2)).sum::<f64>().sqrt(); + // From `a` to `b` along the line from the inside color to the outside one, in its lengths. + let along = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (b[k] - a[k]) * d[k]).sum::<f64>() / len2; + let (Some(a), Some(b)) = (mean([-1, -2], Some(la)), mean([2, 3], Some(lb))) else { return EDGE_STEP }; + let far = match (mean([-3, -4], None), mean([4, 5], None)) { + (Some(a), Some(b)) => along(a, b), + _ => f64::NAN, + }; + let (mut within, mut sides) = (0.0, 0.0); + for ([j, k], l) in [([-1, -2], la), ([2, 3], lb)] { + if let (Some(p), Some(q)) = (pixel(j, Some(l)), pixel(k, Some(l))) { + within += distance(p, q); + sides += 1.0; + } + } + let within = if sides > 0.0 { within / sides } else { f64::NAN }; + let near = along(a, b); + [near, far, near * len2.sqrt(), distance(a, b), within] + } + /// Whether the pixels inside `l` (a loop along pixel edges) all have one label. fn one_colour_within(&self, l: &Loop) -> bool { let mut first = None; @@ -343,7 +417,8 @@ impl Layers<'_> { let hard = std::cell::Cell::new(false); // (Each loop fitted, and whether its own outline is hard.) let fit = |l: &Loop| { - let (sp, is_hard) = fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style)?; + let (sp, is_hard) = + fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c), self.step(c)), &|x, y| self.junction(x, y), &self.style)?; // Every trace straightens the pieces of an outline that lie close to a line (Snap // Curves To Lines straightens more of them), so the outline of a single pixel or // of a region one pixel thick can have all its pieces on one chord, and the loop @@ -530,6 +605,185 @@ pub(crate) fn parallel_map_at_most<T: Sync, R: Send>(_: usize, items: &[T], f: i mod tests { use super::*; + /// `f` of the layers of a `w × 8` image whose pixels in column `x` are the color `color(x)` and + /// have the label `label(x)`, with the palette `palette`, and of the cracks between columns + /// `split - 1` and `split`. + fn across<R>( + w: u32, + split: u32, + color: impl Fn(u32) -> [u8; 3], + label: impl Fn(u32) -> u16, + palette: &[[u8; 3]], + mode: Mode, + f: impl FnOnce(&Layers, Vec<Crack>) -> R, + ) -> R { + let img = Raster::from_fn(w, 8, |x, _| { + let [r, g, b] = color(x); + [r, g, b, 255] + }); + let (w, h) = (w as usize, 8); + let q = Quantized { labels: (0..w * h).map(|i| label((i % w) as u32)).collect(), palette: palette.to_vec() }; + let params = TraceParams { mode, ..TraceParams::default() }; + let n = palette.len(); + let (rank, gradients): (Vec<usize>, Vec<Option<TracedGradient>>) = ((0..n).collect(), vec![None; n]); + let job = Layers { + q: &q, + traced: &q.labels, + rank: &rank, + params: ¶ms, + img: &img, + left_out: vec![false; n], + boxes: vec![None; n], + gradients: &gradients, + bw_tones: [255.0, 0.0], + style: params.outline_style(), + w, + h, + anchors: AtomicUsize::new(0), + max_anchors: usize::MAX, + }; + let cracks = (0..h as i32).map(|y| Crack::new((split as i32 - 1, y), (1, 0), w, h)).collect(); + f(&job, cracks) + } + + /// [`steps_labeled`] with the label 0 left of column `split` and 1 from it. + fn steps(w: u32, split: u32, color: impl Fn(u32) -> [u8; 3], palette: &[[u8; 3]], mode: Mode) -> (Vec<[f64; 5]>, bool) { + steps_labeled(w, split, color, |x| u16::from(x >= split), palette, mode) + } + + /// [`Layers::step`] of every crack between columns `split - 1` and `split` of [`across`]'s image, + /// and whether `subpixel::low_contrast` calls them a contour. Each is read from both sides, which + /// must agree exactly. + fn steps_labeled( + w: u32, + split: u32, + color: impl Fn(u32) -> [u8; 3], + label: impl Fn(u32) -> u16, + palette: &[[u8; 3]], + mode: Mode, + ) -> (Vec<[f64; 5]>, bool) { + across(w, split, color, label, palette, mode, |job, cracks| { + let shares: Vec<[f64; 5]> = cracks + .into_iter() + .map(|c| { + assert_eq!(job.step(c).map(f64::to_bits), job.step(c.flipped()).map(f64::to_bits)); + job.step(c) + }) + .collect(); + let contour = crate::subpixel::low_contrast(&shares); + (shares, contour) + }) + } + + #[test] + fn a_crack_in_a_smooth_area_shows_a_share_of_the_step_that_grows_further_out() { + // Two levels a pixel between colors 40 apart: 135 and 143 two pixels either side (8 levels + // in each channel), 131 and 147 four pixels either side. + let ramp = |x: u32| [(100 + 2 * x) as u8; 3]; + for mode in [Mode::Color, Mode::Grayscale] { + let (shares, contour) = steps(40, 20, ramp, &[[120; 3], [160; 3]], mode); + // (The image changes along the line between the two colors, so its whole change is + // the change along that line; it changes by 2 levels a channel from pixel to pixel.) + let fits = |s: &[f64; 5]| { + (s[0] - 0.2).abs() < 1e-9 + && (s[1] - 0.4).abs() < 1e-9 + && [s[2], s[3]].iter().all(|v| (v - 8.0 * 3f64.sqrt()).abs() < 1e-9) + && (s[4] - 2.0 * 3f64.sqrt()).abs() < 1e-9 + }; + assert!(shares.iter().all(fits), "{mode:?}: {shares:?}"); + assert!(contour); + } + // Black and White's tones are no area's colors. + let (shares, contour) = steps(40, 20, ramp, &[[120; 3], [160; 3]], Mode::BlackAndWhite); + assert!(shares.iter().all(|s| s[0] == 1.0 && s[1] == 1.0 && s[2..].iter().all(|v| v.is_nan())) && !contour, "{shares:?}"); + // By the image's border there is nothing further out. + let (shares, contour) = steps(6, 3, ramp, &[[90; 3], [130; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[1].is_nan()) && contour, "{shares:?}"); + } + + #[test] + fn an_edge_between_flat_colors_is_an_edge_however_far_their_palette_entries_are() { + // A flat gray beside white, the gray in an entry with black: 115 of 245 levels, less than + // half the step between the two entries, but the same further out. + let flat = |x: u32| if x < 10 { [140; 3] } else { [255; 3] }; + let (shares, contour) = steps(20, 10, flat, &[[10; 3], [255; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[0] < 0.5 && s[0] == s[1] && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + // Two flat grays 6 levels apart, in entries 25 apart: under a half of the step and a + // change of under `FLAT`, but nothing changes within either side. + let close = |split: u32| move |x: u32| if x < split { [100; 3] } else { [106; 3] }; + let (shares, contour) = steps(20, 10, close(10), &[[90; 3], [115; 3]], Mode::Color); + assert!(shares.iter().all(|s| (s[0] - 0.24).abs() < 1e-9 && s[0] == s[1] && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + // The same by the image's border, where there is nothing further out. + let (shares, contour) = steps(6, 3, close(3), &[[90; 3], [115; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[1].is_nan() && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + // Two flat colors whose difference is square to the line between their entries: none of + // the step near the crack or further out. + let square = |x: u32| if x < 10 { [100, 100, 100] } else { [100, 106, 94] }; + let (shares, contour) = steps(20, 10, square, &[[90; 3], [115; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[0] == 0.0 && s[1] == 0.0 && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + } + + #[test] + fn an_edge_beside_a_thin_flat_stroke_is_an_edge() { + // White, then a gray stroke two pixels wide in an entry far darker than it (under a half + // of the step from white), then black: four pixels out from the crack the image reaches + // black, more than the whole step. + let color = |x: u32| match x { + 0..10 => [255; 3], + 10..12 => [200; 3], + _ => [0; 3], + }; + let label = |x: u32| match x { + 0..10 => 0, + 10..12 => 1, + _ => 2, + }; + let (shares, contour) = steps_labeled(20, 10, color, label, &[[255; 3], [140; 3], [0; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[0] < 0.5 && s[1] > 1.25 * s[0] && s[4] == 0.0), "{shares:?}"); + assert!(!contour); + } + + #[test] + fn grayscale_reads_an_edge_between_two_colors_by_their_grays() { + // Red beside green: grays 60 and 117 (the palette's entries), whose channels add up to the + // same 200, so read as colors the image would make none of the step between them. + let flat = |x: u32| if x < 10 { [200, 0, 0] } else { [0, 200, 0] }; + let (shares, contour) = steps(20, 10, flat, &[[60; 3], [117; 3]], Mode::Grayscale); + assert!(shares.iter().all(|s| s[0] == 1.0 && s[1] == 1.0), "{shares:?}"); + assert!(!contour); + } + + #[test] + fn grayscale_places_an_edge_between_two_colors_by_their_grays() { + // The same red and green: by their grays, each is all of its own entry, so the edge lies on + // the crack. Read as colors, each would be mostly the other's. + let flat = |x: u32| if x < 10 { [200, 0, 0] } else { [0, 200, 0] }; + let shifts: Vec<f64> = across( + 20, + 10, + flat, + |x| u16::from(x >= 10), + &[[60; 3], [117; 3]], + Mode::Grayscale, + |job, cracks| cracks.into_iter().map(|c| job.shift(c)).collect(), + ); + assert!(shifts.iter().all(|&s| s == 0.0), "{shifts:?}"); + } + + #[test] + fn an_edge_between_colors_off_the_line_between_the_palettes_two_is_an_edge() { + // Red beside green traced with black and white entries: a change of 226 levels, none of it + // from black towards white. + let flat = |x: u32| if x < 10 { [160, 0, 0] } else { [0, 160, 0] }; + let (shares, contour) = steps(20, 10, flat, &[[0; 3], [255; 3]], Mode::Color); + assert!(shares.iter().all(|s| s[0] == 0.0 && s[1] == 0.0 && (s[3] - 160.0 * 2f64.sqrt()).abs() < 1e-9), "{shares:?}"); + assert!(!contour); + } + #[test] fn fit_in_chunks_keeps_the_order_and_stops_at_the_first_refusal() { let items: Vec<usize> = (0..1000).collect(); diff --git a/crates/trace/src/logo.rs b/crates/trace/src/logo.rs index f3b5dd03d5..4f6d258b92 100644 --- a/crates/trace/src/logo.rs +++ b/crates/trace/src/logo.rs @@ -25,7 +25,7 @@ mod testutil; mod unmix; pub(crate) use bezier::{fit_cubics, tangent}; -pub(crate) use geom::{Cubic, all_within, resample_closed, resample_open, sample_cubics}; +pub(crate) use geom::{Cubic, all_within, resample_closed, resample_open, sample_cubics, smooth_closed, smooth_pinned}; pub(crate) use outline::{Stats, Style, fit_open, fit_outline}; pub(crate) use palette::parse_hex; pub(crate) use pipeline::trace_logo; diff --git a/crates/trace/src/logo/geom.rs b/crates/trace/src/logo/geom.rs index b33b358856..d81528e713 100644 --- a/crates/trace/src/logo/geom.rs +++ b/crates/trace/src/logo/geom.rs @@ -119,13 +119,18 @@ pub(crate) fn smooth_closed(p: &[Point], sigma: f64) -> Vec<Point> { } /// Gaussian smoothing of an open run with both ends pinned (the run is extended by odd reflection -/// so tangents at the ends survive). +/// so tangents at the ends survive), by at most an eighth of the run's samples. pub(crate) fn smooth_open(p: &[Point], sigma: f64) -> Vec<Point> { + smooth_pinned(p, sigma.max(0.4).min(p.len() as f64 / 8.0)) +} + +/// [`smooth_open`] by `sigma` (in samples, at least 0.4) whatever the run's length. +pub(crate) fn smooth_pinned(p: &[Point], sigma: f64) -> Vec<Point> { let n = p.len(); if n < 5 { return p.to_vec(); } - let s = sigma.max(0.4).min(n as f64 / 8.0).max(0.4); + let s = sigma.max(0.4); let k = kernel(s); let r = (k.len() - 1) / 2; let r_ext = r.min(n - 2); diff --git a/crates/trace/src/logo/outline.rs b/crates/trace/src/logo/outline.rs index 35f655b37f..9eaad508c9 100644 --- a/crates/trace/src/logo/outline.rs +++ b/crates/trace/src/logo/outline.rs @@ -3,6 +3,7 @@ use super::bezier::{fit_cubics, tangent}; use super::geom::{ Cubic, all_within, arc_cubics, circle_fit, find_corners, finite, resample_closed, resample_open, sample_cubics, smooth_closed, smooth_open, + smooth_pinned, }; use super::restore::{RestoreParams, restore_corners}; use vectorcraft_geom::Point; @@ -28,6 +29,10 @@ pub(crate) struct Style { /// Straight pieces within a few degrees of level or upright are made exactly so (by the /// tracer's other modes; Flat Logo leaves them). pub upright: bool, + /// The outline is the contour of a smooth area: a run between corners is smoothed by `sigma` + /// whatever its length (not by at most an eighth of it), and the fit is checked against the + /// outline smoothed by `sigma` (not against the outline itself). + pub contour: bool, } impl Default for Style { @@ -46,6 +51,7 @@ impl Default for Style { max_dev: 1.4, check_step: 0.03, upright: false, + contour: false, } } } @@ -111,7 +117,7 @@ fn fit_run(run: &[Point], sg: f64, st: &Style, stats: &mut Stats, segs: &mut Vec } return; } - let sm = smooth_open(run, sg); + let sm = if st.contour { smooth_pinned(run, sg) } else { smooth_open(run, sg) }; let (Some(&a), Some(&b)) = (sm.first(), sm.last()) else { return }; let len = a.distance(b); if st.snap && len > 1.5 { @@ -267,8 +273,10 @@ pub(crate) fn fit_outline(p: &[Point], st: &Style, stats: &mut Stats) -> Option< return None; } let mut local = Stats::default(); - // The reference is the raw outline, densified so the check never depends on how sparse it is. - let reference = resample_closed(p, st.check_step / 0.3).map_or_else(|| p.to_vec(), |(d, _)| d); + // The reference is the raw outline, densified so the check never depends on how sparse it is + // (and smoothed, for the contour of a smooth area). + let reference = resample_closed(p, st.check_step / 0.3) + .map_or_else(|| p.to_vec(), |(d, step)| if st.contour { smooth_closed(&d, st.sigma / step) } else { d }); let checked = |st: &Style, local: &mut Stats| { styled_fit(p, st, local).filter(|s| sane(s)).filter(|s| { let samples = sample_cubics(s, st.check_step); diff --git a/crates/trace/src/subpixel.rs b/crates/trace/src/subpixel.rs index 91a24a1da4..550fb87d2f 100644 --- a/crates/trace/src/subpixel.rs +++ b/crates/trace/src/subpixel.rs @@ -67,8 +67,62 @@ pub(crate) struct Piece { pub hard: bool, /// The piece is a whole loop without a junction. pub closed: bool, + /// The piece is a contour of a smooth area, not an edge (see [`low_contrast`]). + pub contour: bool, } +/// Whether a stretch of outline is the contour of a smooth area (where a gradient or a shaded +/// surface was split into bands of two colors) rather than an edge of the image, from what +/// `across` says of each of its cracks (see `Layers::step`): the share of the step from one color to +/// the other that the image makes from the second and third pixels on one side of the crack to those +/// on the other (near), from the fourth and fifth (far), the near change along the line between the +/// two colors, the whole near change in any direction, and the change within each side, each change +/// an RGB distance (0 to 255 a channel). An edge makes all of the step near the crack, +/// anti-aliased or not, and none further out; a smooth area makes a fraction of it near the crack +/// and a larger fraction further out, and changes within each side too. +/// +/// Here, over the stretch: +/// +/// - the near share is under one half; +/// - the far share is at least one and a quarter times the near one, or the near change along the +/// line is under [`FLAT`], or no crack has a far share; +/// - the sides change within themselves, by at least a thirty-second of the near change along the +/// line; +/// - and the whole near change is under twice the change along the line, or under twice [`FLAT`]. +/// +/// Between two flat colors that the palette put in entries further apart than they are, the near +/// share can be under one half, but nothing changes within either side. Between two colors off the +/// line between their entries, such as a red and a green traced with black and white, the shares can +/// be nothing at all, but the whole change is large. Values that are not a number are left out. The +/// means are summed in sorted order, so the stretch is decided the same from either side. +pub(crate) fn low_contrast(steps: &[[f64; 5]]) -> bool { + let mean = |k: usize| { + let mut v: Vec<f64> = steps.iter().map(|s| s[k]).filter(|s| !s.is_nan()).collect(); + v.sort_by(f64::total_cmp); + (!v.is_empty()).then(|| v.iter().sum::<f64>() / v.len() as f64) + }; + let along = mean(2); + if mean(3).is_some_and(|change| change >= 2.0 * along.unwrap_or(0.0).max(FLAT)) { + return false; + } + if !mean(4).is_some_and(|within| within > 0.0 && within >= along.unwrap_or(0.0) / 32.0) { + return false; + } + match (mean(0), mean(1)) { + (Some(near), Some(far)) => near < 0.5 && (far >= 1.25 * near || along.is_some_and(|l| l < FLAT)), + (Some(near), None) => near < 0.5, + _ => false, + } +} + +/// A change between colors (an RGB distance, 0 to 255 a channel) under which an area is nearly +/// flat (see [`low_contrast`]). +const FLAT: f64 = 12.0; + +/// What `across` says of a crack that is an edge whatever the image does there: a crack on the +/// image's border, or one that the image's pixels cannot be read across (see [`low_contrast`]). +pub(crate) const EDGE_STEP: [f64; 5] = [1.0, 1.0, f64::NAN, f64::NAN, f64::NAN]; + /// What lies across a crack on the image's border: one neighbour per side, so that the image's /// corners are junctions and stay exact. fn border(dir: (i32, i32)) -> u32 { @@ -84,7 +138,8 @@ fn border(dir: (i32, i32)) -> u32 { /// The outline of `l` (a loop of a `w × h` image) as points. /// /// `across` says, for a crack, how far (in pixels, −0.5 to 0.5, positive towards the outside -/// pixel) the edge lies from it, and which region is on its other side. +/// pixel) the edge lies from it, which region is on its other side, and what the image does across +/// it between the two regions' colors (see [`low_contrast`]; the same read from either side). /// /// The outline comes in pieces, cut at its junctions: the pixel corners where the region on the /// other side changes (three or more regions meet there, or the image's border begins), and @@ -100,18 +155,19 @@ pub(crate) fn outline( l: &Loop, w: usize, h: usize, - across: &dyn Fn(Crack) -> (f64, u32), + across: &dyn Fn(Crack) -> (f64, u32, [f64; 5]), junction_at: &dyn Fn(i32, i32) -> Option<Point>, ) -> Vec<Piece> { let v = &l.pts; let n = v.len(); let at = |i: usize| Point::new(f64::from(v[i % n].0), f64::from(v[i % n].1)); if n < 4 { - return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }]; + return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true, contour: false }]; } let mut edges = Vec::with_capacity(n); - // Per crack: its shift, the region across it, and the edge it is on. - let (mut shifts, mut others, mut edge_of): (Vec<f64>, Vec<u32>, Vec<usize>) = (vec![], vec![], vec![]); + // Per crack: its shift, the region across it, the edge it is on, and what the image does + // across it between the two colors. + let (mut shifts, mut others, mut edge_of, mut steps): (Vec<f64>, Vec<u32>, Vec<usize>, Vec<[f64; 5]>) = (vec![], vec![], vec![], vec![]); for i in 0..n { let (a, b) = (v[i], v[(i + 1) % n]); let (dx, dy) = (b.0 - a.0, b.1 - a.1); @@ -128,19 +184,20 @@ pub(crate) fn outline( _ => ((sx, sy - 1), (sx - 1, sy - 1)), }; let crack = Crack { inside, step: (outside.0 - inside.0, outside.1 - inside.1), w, h }; - let (s, other) = match crack.at(1) { + let (s, other, step) = match crack.at(1) { Some(_) => across(crack), // The edge of the image is where it is. - _ => (0.0, border(dir)), + _ => (0.0, border(dir), EDGE_STEP), }; shifts.push(if s.is_finite() { (s.clamp(-0.5, 0.5) * 1024.0).round() / 1024.0 } else { 0.0 }); others.push(other); edge_of.push(i); + steps.push(step); } } let cracks = shifts.len(); if cracks == 0 { - return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }]; + return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true, contour: false }]; } // The pixel corner each crack starts at. let corner_of = |c: usize| { @@ -244,6 +301,7 @@ pub(crate) fn outline( let len = (1..cracks - c).find(|&k| junction((start + c + k) % cracks)).unwrap_or(cracks - c); let own: Vec<f64> = (0..len).map(|k| shifts[(start + c + k) % cracks]).collect(); let hard = is_hard(&own); + let contour = low_contrast(&(0..len).map(|k| steps[(start + c + k) % cracks]).collect::<Vec<_>>()); let mut pts = Vec::with_capacity(len + 2); for k in 0..len { let crack = (start + c + k) % cracks; @@ -271,11 +329,11 @@ pub(crate) fn outline( let (x, y) = corner_of(next); pts.push(junctions[next].unwrap_or(Point::new(f64::from(x), f64::from(y)))); } - pieces.push(Piece { pts, hard, closed: first.is_none() }); + pieces.push(Piece { pts, hard, closed: first.is_none(), contour }); c += len; } if first.is_none() && pieces.first().is_some_and(|p| p.pts.len() < 3) { - return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true }]; + return vec![Piece { pts: (0..n).map(at).collect(), hard: true, closed: true, contour: false }]; } pieces } @@ -297,7 +355,7 @@ mod tests { /// The outline with one neighbour all round (the image's border too): its points, and whether /// it is hard. fn whole(l: &Loop, w: usize, h: usize, shift: &dyn Fn(Crack) -> f64) -> (Vec<Point>, bool) { - let mut pieces = outline(l, w, h, &|c| (shift(c), 0), &grid); + let mut pieces = outline(l, w, h, &|c| (shift(c), 0, EDGE_STEP), &grid); let hard = pieces.iter().all(|p| p.hard); // Only the image's border makes junctions here: drop each piece's closing point. let split = pieces.len() > 1; @@ -354,7 +412,7 @@ mod tests { let (a, b) = (c.at(0).unwrap(), c.at(1).unwrap()); assert_eq!((c.at(-1), c.at(2)), (Some(a - 1), Some(b + 1))); assert_eq!((c.at(-3), c.at(4)), (None, None)); - (0.0, 0) + (0.0, 0, EDGE_STEP) }, &grid, ); @@ -366,6 +424,76 @@ mod tests { assert!(pts.contains(&Point::new(3.0, 0.0)) && pts.contains(&Point::new(3.0, 4.0))); } + #[test] + fn a_stretch_is_a_contour_when_it_shows_a_small_share_of_the_step_that_grows_further_out() { + // A smooth area: about a fifth of the step near the cracks, more further out, with noise. + assert!(low_contrast(&[[0.1, 0.5, 4.0, 9.0, 3.0], [0.3, 0.3, 12.0, 15.0, 4.0], [0.2, 0.4, 8.0, 11.0, 2.0]])); + assert!(low_contrast(&[[0.2, 0.26, 20.0, 24.0, 5.0], [0.2, 0.26, 20.0, 24.0, 5.0]])); + assert!(!low_contrast(&[[0.2, 0.24, 20.0, 24.0, 5.0], [0.2, 0.24, 20.0, 24.0, 5.0]])); + // Flat color beside flat color: the same share near the crack and further out. + assert!(!low_contrast(&[[0.45, 0.45, 40.0, 40.0, 1.0], [0.47, 0.5, 42.0, 42.0, 2.0], [0.46, 0.44, 41.0, 41.0, 1.0]])); + // A near change of a few levels, the same further out: a nearly flat area whose bands + // noise divides (and turns in any direction, also within each side). + assert!(low_contrast(&[[0.22, 0.22, 3.0, 20.0, 15.0], [0.24, 0.2, 3.5, 18.0, 12.0]])); + assert!(!low_contrast(&[[0.22, 0.22, 12.0, 14.0, 15.0], [0.24, 0.2, 12.0, 14.0, 12.0]])); + // Two close flat colors, the same near the crack and further out, and nothing changes + // within either side: no contour, however small their change. + assert!(!low_contrast(&[[0.24, 0.24, 10.4, 10.4, 0.0], [0.24, 0.24, 10.4, 10.4, 0.0]])); + // The same for two flat colors whose difference is square to the line between their + // entries: none of the step, and no change within the sides either. + assert!(!low_contrast(&[[0.0, 0.0, 0.0, 14.0, 0.0]])); + // The change within the sides must be at least a thirty-second of the change along the line. + assert!(low_contrast(&[[0.2, 0.4, 16.0, 16.0, 0.5]])); + assert!(!low_contrast(&[[0.2, 0.4, 16.0, 16.0, 0.49]])); + // An edge: all of the step. + assert!(!low_contrast(&[[1.0, 1.0, 40.0, 40.0, 0.0], [0.9, 1.0, 36.0, 37.0, 1.0]])); + // An edge between colors off the line between the two entries: none of the step, but a + // large change. The whole change must stay under twice the change along the line. + assert!(!low_contrast(&[[0.0, 0.0, 0.0, 226.0, 5.0], [0.0, 0.0, 0.0, 226.0, 5.0]])); + assert!(low_contrast(&[[0.2, 0.4, 14.0, 27.0, 4.0]])); + assert!(!low_contrast(&[[0.2, 0.4, 14.0, 28.0, 4.0]])); + // The whole stretch counts: one half and more near the crack is no contour. + assert!(!low_contrast(&[[0.2, 1.0, 8.0, 10.0, 2.0], [0.9, 1.0, 36.0, 36.0, 0.0]])); + // Without far shares (by the image's border), the near share decides, if the sides change. + assert!(low_contrast(&[[0.3, f64::NAN, 30.0, 32.0, 6.0], [0.2, f64::NAN, 20.0, 22.0, 5.0]])); + assert!(!low_contrast(&[[0.3, f64::NAN, 30.0, 32.0, 0.0], [0.2, f64::NAN, 20.0, 22.0, 0.0]])); + // Values that are not a number say nothing; no share at all, or no change within the + // sides at all, no contour. + assert!(low_contrast(&[[f64::NAN; 5], [0.2, 0.4, 20.0, 22.0, 4.0]])); + assert!(!low_contrast(&[])); + assert!(!low_contrast(&[[f64::NAN, 0.2, 3.0, 5.0, 1.0]])); + assert!(!low_contrast(&[[0.2, 0.4, 20.0, 22.0, f64::NAN]])); + } + + #[test] + fn a_contour_is_decided_for_the_whole_piece_and_the_same_from_either_side() { + // Left and right halves meet along a staircase; the cracks in the upper rows show a tenth of + // the step near them (4 levels) and three tenths further out, and change within each side, + // those in the lower ones all of it. + let (w, h) = (12, 10); + let left: Vec<bool> = (0..w * h).map(|i| (i % w) as f64 + 0.5 < 4.0 + (i / w) as f64 * 0.4).collect(); + let right: Vec<bool> = left.iter().map(|b| !b).collect(); + for (low_rows, contour) in [(9, true), (3, false)] { + // (By the upper of the crack's two pixels: the same from either side.) + let step = |c: Crack| { + if c.at(0).unwrap().min(c.at(1).unwrap()) / w < low_rows { [0.1, 0.3, 4.0, 4.0, 4.0] } else { [1.0, 1.0, 40.0, 40.0, 0.0] } + }; + let a = outline(&loop_of(&left, w, h), w, h, &|c| (0.0, 1, step(c)), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|c| (0.0, 2, step(c)), &grid); + let inner = |pieces: &[Piece]| { + pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() + }; + assert_eq!((inner(&a).contour, inner(&b).contour), (contour, contour), "{low_rows} low rows"); + // The image's border is no contour: its cracks have nothing across them. + assert!( + a.iter() + .chain(&b) + .filter(|p| p.pts.iter().all(|q| q.x == 0.0 || q.y == 0.0 || q.x == w as f64 || q.y == h as f64)) + .all(|p| !p.contour) + ); + } + } + #[test] fn a_shift_that_is_not_a_number_moves_nothing() { let mask = [true, true, false, false, true, true, false, false]; @@ -400,7 +528,7 @@ mod tests { let across = |c: Crack| { let (a, b) = (c.at(0).unwrap(), c.at(1).unwrap()); let wave = 0.4 * ((a + b) as f64).sin(); - (if labels[a] < labels[b] { wave } else { -wave }, labels[b]) + (if labels[a] < labels[b] { wave } else { -wave }, labels[b], EDGE_STEP) }; let pieces = |label| { let mask: Vec<bool> = labels.iter().map(|&l| l == label).collect(); @@ -446,8 +574,8 @@ mod tests { let left: Vec<bool> = (0..w * h).map(|i| (i % w) as f64 + 0.5 < 4.0 + (i / w) as f64 * 0.4).collect(); let right: Vec<bool> = left.iter().map(|b| !b).collect(); let wave = |c: Crack| 0.4 * ((c.at(0).unwrap() + c.at(1).unwrap()) as f64).sin(); - let a = outline(&loop_of(&left, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 1), &grid); - let b = outline(&loop_of(&right, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 2), &grid); + let a = outline(&loop_of(&left, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 1, EDGE_STEP), &grid); + let b = outline(&loop_of(&right, w, h), w, h, &|c| (if left[c.at(0).unwrap()] { wave(c) } else { -wave(c) }, 2, EDGE_STEP), &grid); // The piece that is not on the image's border, from either side. let inner = |pieces: &[Piece]| { pieces.iter().find(|p| p.pts.iter().any(|q| q.x > 0.0 && q.x < w as f64 && q.y > 0.0 && q.y < h as f64)).unwrap().clone() diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 4e138dfd5b..0d2b725efb 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1281,6 +1281,92 @@ fn hairlines_keep_their_colour_in_color_mode() { assert_eq!(q.labels.iter().filter(|&&l| l == black).count(), 8 * 80 * 2 - 64); } +/// Where a point lies across [`wavy_bands`]' gradient: the same all along each of its contours. +fn wavy_u(x: f64, y: f64) -> f64 { + x + 8.0 * (y / 23.0).sin() +} + +/// The dark square of [`wavy_bands`]: its left, top, right and bottom sides. +const WAVY_SQUARE: [f64; 4] = [100.3, 40.2, 132.6, 80.7]; + +/// A 256 × 128 gradient from blue to orange whose contours wave from top to bottom, with 6 levels +/// of noise per channel, and a dark square with crisp sides on it, drawn anti-aliased. +fn wavy_bands() -> Raster { + const LOW: [f64; 3] = [30.0, 60.0, 120.0]; + const HIGH: [f64; 3] = [230.0, 170.0, 60.0]; + Raster::from_fn(256, 128, |x, y| { + let mut sum = [0.0; 3]; + for i in 0..16 { + let (sx, sy) = (f64::from(x) + (f64::from(i % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(i / 4) + 0.5) / 4.0); + let [l, t, r, b] = WAVY_SQUARE; + let c = if (l..r).contains(&sx) && (t..b).contains(&sy) { + [20.0; 3] + } else { + let along = ((wavy_u(sx, sy) + 8.0) / 272.0).clamp(0.0, 1.0); + [0, 1, 2].map(|k| LOW[k] + (HIGH[k] - LOW[k]) * along) + }; + for k in 0..3 { + sum[k] += c[k] / 16.0; + } + } + let c = |k: usize| (sum[k] + 6.0 * jitter(x, y, k as u32)).round().clamp(0.0, 255.0) as u8; + [c(0), c(1), c(2), 255] + }) +} + +/// How far the borders between the bands of a [`wavy_bands`] trace stray from contours of the +/// gradient: the RMS, over points along every curve more than 6 px from the image's border and from +/// the square, of the point's [`wavy_u`] less the mean of its stretch (the points in a row along +/// one curve), over stretches that run at least 30 px down the image. Also returns their number. +fn contour_spread(res: &TraceResult) -> (f64, usize) { + use vectorcraft_geom::ParamCurve; + let [l, t, r, b] = WAVY_SQUARE; + let away = |p: Point| p.x > 6.0 && p.x < 250.0 && p.y > 6.0 && p.y < 122.0 && !(p.x > l - 6.0 && p.x < r + 6.0 && p.y > t - 6.0 && p.y < b + 6.0); + let mut stretches: Vec<Vec<Point>> = vec![]; + for sp in res.paths.iter().flat_map(|p| &p.path.subpaths) { + let mut stretch = vec![]; + for p in (0..sp.segment_count()).flat_map(|i| (0..64).map(move |k| sp.segment(i).eval(f64::from(k) / 64.0))) { + if away(p) { + stretch.push(p); + } else if !stretch.is_empty() { + stretches.push(std::mem::take(&mut stretch)); + } + } + stretches.push(stretch); + } + stretches.retain(|s| s.iter().map(|p| p.y).fold(f64::MIN, f64::max) - s.iter().map(|p| p.y).fold(f64::MAX, f64::min) >= 30.0); + let mut sum = 0.0; + let mut n = 0; + for s in &stretches { + let mean = s.iter().map(|p| wavy_u(p.x, p.y)).sum::<f64>() / s.len() as f64; + sum += s.iter().map(|p| (wavy_u(p.x, p.y) - mean).powi(2)).sum::<f64>(); + n += s.len(); + } + ((sum / n.max(1) as f64).sqrt(), stretches.len()) +} + +#[test] +fn the_borders_of_a_gradients_bands_follow_its_contours() { + let res = trace(&wavy_bands(), &sixteen_colors()); + // The square's sides are edges: it keeps its four corners. + let [l, t, r, b] = WAVY_SQUARE; + let square = res.paths.iter().find(|p| p.color.iter().all(|&c| c < 40)).expect("the dark square"); + let nearest = [Point::new(l, t), Point::new(r, t), Point::new(r, b), Point::new(l, b)].map(|corner| { + square.path.subpaths[0] + .anchors + .iter() + .filter(|a| a.kind == vectorcraft_geom::AnchorKind::Corner) + .map(|a| a.p.distance(corner)) + .fold(f64::MAX, f64::min) + }); + assert!(nearest.iter().all(|&d| d < 0.3), "the nearest corner anchors to the square's corners: {nearest:?} px"); + let (spread, stretches) = contour_spread(&res); + // Each of the borders between the 15 or so bands, once for the band on either side of it. + assert!(stretches >= 20, "{stretches} stretches"); + // (Smoothed as edges are, 1.13 px.) + assert!(spread < 0.9, "the borders stray {spread} px from the gradient's contours"); +} + #[test] fn the_bands_of_a_gradient_stay_apart() { let img = Raster::from_fn(256, 64, |x, y| [x as u8, (x / 2 + y) as u8, 200 - (x / 2) as u8, 255]); diff --git a/docs/image-trace-quality.md b/docs/image-trace-quality.md index 417aab0192..034e009f17 100644 --- a/docs/image-trace-quality.md +++ b/docs/image-trace-quality.md @@ -126,6 +126,36 @@ flipped crack's, and the image does have cracks the one-sided reading gets wrong 48 px noisy ripple in Black and White at thresholds 140, 157, 170 and Paths 0, 50, 100, sampled on the 4 × 4 grid per pixel the shared-boundary check uses). With the one-sided reading 482–1,228 sample points per trace were uncovered; now none are. The crate's other tests pass unchanged. +## Band borders in smooth areas +In a gradient or a shaded area, the palette colors on either side of a band border differ by more than +the image changes across it. Pixel noise placed the border, and the fitter smoothed it over 0.5 px as it +smooths an edge, so the border wobbled, kept stair steps and took many anchors. For each crack between +two colors, `Layers::step` now reads the share of the step between the two colors that the image makes +next to the crack and a few pixels further out, how far the image changes next to the crack along the +line between the two colors and in any direction, and how much it changes within each side, as RGB +distances (0 to 255 a channel). `subpixel::low_contrast` makes a piece of outline the contour of a smooth +area when, over its cracks, the near share is under one half; the far share is at least 1.25 times the +near one, the change along the line is under 12 (about 7 levels in each channel of a gray), or no crack +has a far share; the sides change within themselves, by at least 1/32 of the change along the line; and the +whole change is under twice the change along the line, or under 24. Black and White traces do not use +the rule. A contour piece is smoothed over 4 px (a closed one over at most an eighth of its smaller width +or height), keeps no corners, is fitted to at least 0.5 px and is checked against its smoothed points. +Grayscale now reads each pixel as its gray for this rule and for every edge's sub-pixel shift +(`Layers::shift`), which read the pixels' colors against gray palette entries before. +Measured with the harness below, before and after the change: Color traces of the "flat colour art" +inputs, and corners % in every row, do not change. Shades of Gray on those inputs: off mean 0.263 to 0.165 px, +off max 1.47 to 1.04 px, anchors 196 to 122. Gradient and photo inputs: anchors 2,138 to 798 (16 Colors, +gradient and photo), 1,372 to 200 (16 Colors, linear gradients), 10,063 to 6,766 (High Fidelity Photo, +gradient and photo); ΔE and edge ΔE change by −0.25 to +0.02. Specks rise by up to 0.8 per image (16 +Colors on gradient and photo 0.0 to 0.8, Auto Color 0.0 to 0.5). Auto Color, enhanced (Shapes on) on +gradient and photo leaves a gap of 0.3 px (was 0.0) and an overlap of 0.4 px (was 0.1). The tables below +are the previous measurement, not a rerun. +Tests on code-drawn images: `tests::the_borders_of_a_gradients_bands_follow_its_contours` (a 256 × 128 +gradient whose contours wave, with noise, and a dark square with crisp sides: the band borders stray +under 0.9 px RMS from the gradient's contours, 1.13 px when smoothed as edges, and the square keeps its +four corners); `layers::tests` (a ramp, flat colors in palette entries far apart, close flat colors, a +thin flat stroke, colors off the line between their entries, and Grayscale's grays, each read from both +sides of the crack) and `subpixel::tests` (the rule, and one decision for a whole piece from either side). ## A simpler contour walk `contour::trace_mask` follows each region's outline along the pixel cracks with the inside on the right. It used to build a table of outgoing directions for every pixel corner in a raster pass and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..c51d996a4b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1237,6 +1237,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, stacked layers only under their own areas:** with Method Overlapping, a layer reaches under the colors above it only within the connected areas that hold its own color, so a shape that sits apart from a color is no longer written again, at the same bounds, in that color, and the edge of that color's layer no longer shows through that shape's anti-aliased rim. *Measured* by `crates/trace/tests/quality` on each preset's own inputs: High Fidelity Photo 121 paths to 69.6 with ΔE 1.74 to 1.72, Low Fidelity Photo 37.1 paths to 26.1. Code-drawn disc, square and line tests in `crates/trace`. - **Image Trace, notches read only their own piece:** the notch rule of `subpixel::outline` reads only the shared piece, so both regions of an edge cut a corner pixel alike and leave no gap; a code-drawn noisy multi-colour coverage test in `crates/trace` (see [quality notes](image-trace-quality.md#notches-read-only-their-own-piece)). - **Image Trace, same shift from either side of an edge:** a crack's sub-pixel shift is averaged from its two sides, so abutting regions on one- and two-pixel slivers get the same points and leave nothing uncovered; code-drawn regressions in `crates/trace` (see [quality notes](image-trace-quality.md#same-sub-pixel-shift-from-either-side-of-an-edge)). +- **Image Trace, band borders in smooth areas:** in Color and Grayscale traces, a border between two colors where the image changes gradually is the contour of a smooth area, by VectorCraft's own rule (`subpixel::low_contrast`, distances measured in RGB, 0 to 255 a channel): next to the border the image makes less than half the step between the two colors; it makes more of the step a few pixels further out, or changes by a distance under 12 (about 7 levels in each channel of a gray); the pixels on each side change too; and its whole change next to the border is under twice its change from one color towards the other, or under 24. Grayscale reads each pixel as its gray, for this rule and for every edge's sub-pixel position. Such a border is smoothed over about 4 px instead of 0.5 px (0.9 px for an edge without anti-aliasing), keeps no corners and is fitted to at least 0.5 px, so band borders in photos and gradients follow the image's contours with fewer anchors; without Shapes, both colors beside it share the same curve. Color traces of clean flat-color art and Black and White traces are unchanged, Grayscale traces of colored art place their edges closer to the art's outlines, and noisy flat-color art can take fewer anchors where noise splits a color into bands; see [quality notes](image-trace-quality.md#band-borders-in-smooth-areas). Tests: `tests::the_borders_of_a_gradients_bands_follow_its_contours`, `layers::tests` and `subpixel::tests` in `crates/trace`. Scores and hours unchanged. - **Image Trace, cut-out edges:** in Color and Grayscale, a semi-transparent pixel (alpha 128 to 254) on the edge of a cut-out image, beside a pixel that is less than half opaque, traces as a color of the fully opaque pixels within two pixels of it when its own color is that color or a darker shade of it, and of several such colors, as the one the most of those pixels have (art across a transparent gap does not count), so a darkened edge tone no longer traces as a 1 px sliver of an unrelated palette color, while a thin semi-transparent outline of another color keeps its own; code-drawn cut-out, opaque-edge and thin-outline tests in `crates/trace`. - **Image Trace, cut-out edges choose no color:** in Color and Grayscale, the edge pixels that take the color beside them, and the semi-transparent pixels within two pixels of them whose color is also the opaque art's color blended with the background, do not count when the palette or the grays are chosen, so the edge of a cut-out image no longer adds a second shade of a color to Auto Color's palette, takes away an outline's color or takes a gray of its own; beside those pixels, the anti-aliasing between a color and an outline drawn along the edge is no color of Auto Color's own when it lies at least 0.45 of the way from the color to the outline and at least one in 32 of its pixels has both around it (edge pixels that choose no color count when they lie within 2.5 Oklab × 100 of the nearest of those colors): its pixels trace as the outline, so a thin outline stays a ring of few pieces, except beside an outline along the edge with a region of 8 pixels of its own, where each takes the nearer of the two, so a wider outline is not drawn wider; an outline over the edge in the color of other art traces in that color, while an opaque rim drawn along the edge keeps its color, also where other art reaches it; a thin semi-transparent outline of another color still counts, and opaque images trace as before; code-drawn cut-out outline, rim, opaque line and grayscale edge tests in `crates/trace`. - **Image Trace, simpler contour walk:** `contour::trace_mask` works out each turn from the pixels during the walk, with no precomputed direction table and no assertion; identical loops to the previous walk on every 4 × 4 mask (see [quality notes](image-trace-quality.md#a-simpler-contour-walk)). From 5d40a1c6d088c0fdf6c592c64bb6a332544811f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 06:58:00 -0800 Subject: [PATCH 75/79] View: the art keeps its size at a zoom whatever the UI Scaling Preferences > User Interface > UI Scaling sets egui's zoom factor, and the canvas mapped one document point to one of the interface's points at 100%. A document point then covered UI Scaling times as many device pixels, so the art grew and shrank with the interface. The canvas now maps the document at the view's zoom divided by the UI zoom (View::screen_zoom, Xf::at). The rendered frame, the pointer, rulers, grids, Fit and Actual Size, pan, scroll, zoom and turn about a point, the Zoom tool's marquee, Pixel Preview's check, tiled panes, the Navigator's view box and the tools' tolerances use that mapping. The zoom value means the same at every UI Scaling, and the interface still follows UI Scaling. fit_zoom, zoom_about, rotate_about, pan_pixels and scroll_by take the UI zoom, and Xf::new is now test-only. Found while looking into a report by bortek about the art's size at 100%. --- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/frame.rs | 5 +- crates/ui-egui/src/app/view.rs | 9 +- crates/ui-egui/src/canvas.rs | 25 +- crates/ui-egui/src/canvas/input.rs | 19 +- crates/ui-egui/src/canvas/panes.rs | 20 +- crates/ui-egui/src/canvas/panes/tests.rs | 29 ++- crates/ui-egui/src/canvas/tests.rs | 6 +- crates/ui-egui/src/canvas/tests_ui_scaling.rs | 214 ++++++++++++++++++ crates/ui-egui/src/canvas/view.rs | 49 ++-- crates/ui-egui/src/control.rs | 4 +- crates/ui-egui/src/dialogs/gradient_stop.rs | 5 +- crates/ui-egui/src/dialogs/revolve_gizmo.rs | 2 +- crates/ui-egui/src/dialogs/simplify_slider.rs | 3 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panels/navigator.rs | 61 ++++- crates/ui-egui/src/panels/tabs.rs | 4 +- crates/ui-egui/src/place.rs | 2 +- crates/ui-egui/src/state.rs | 17 +- docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + 23 files changed, 410 insertions(+), 77 deletions(-) create mode 100644 crates/ui-egui/src/canvas/tests_ui_scaling.rs diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index b07964b225..91c7155e75 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -404,7 +404,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "view.actualSize", "Actual Size", "Cmd+1", - "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)", + "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling); the same at every UI Scaling", ), ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), ( diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 3ce7143224..7865375b69 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -127,6 +127,7 @@ impl VectorcraftApp { updates_rt: Default::default(), frame_rate: Default::default(), canvas_rect: None, + canvas_ui_zoom: 1.0, control_rotate_press: None, hover_doc: None, titlebar_from_env: false, diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 0b87eb16b8..ce9aa6bf93 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -1,4 +1,4 @@ -use super::{GeometryStamp, InputGeneration, VectorcraftApp, canvas}; +use super::{GeometryStamp, InputGeneration, VectorcraftApp}; impl VectorcraftApp { /// The host iteration count: one increment per `App::logic`/`App::ui` iteration (or a @@ -9,8 +9,7 @@ impl VectorcraftApp { /// The canvas on screen in document coordinates (none before it is laid out). pub fn screen_frame(&self) -> Option<vectorcraft_tools::ScreenFrame> { - let (rect, view) = (self.canvas_rect?, self.view()?); - let xf = canvas::Xf::new(rect, view); + let (rect, xf) = (self.canvas_rect?, self.canvas_xf()?); let px = |x: f32, y: f32| xf.delta_to_doc(egui::vec2(x, y)); Some(vectorcraft_tools::ScreenFrame { origin: xf.to_doc(rect.left_top()), diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs index f88367cb60..0949b9290f 100644 --- a/crates/ui-egui/src/app/view.rs +++ b/crates/ui-egui/src/app/view.rs @@ -110,9 +110,16 @@ impl VectorcraftApp { self.views.get_mut(i) } + /// The mapping between the document and the canvas of the last frame, at the active view (none + /// before the canvas is laid out or without a document). + pub(crate) fn canvas_xf(&self) -> Option<crate::canvas::Xf> { + Some(crate::canvas::Xf::at(self.canvas_rect?, self.view()?, self.canvas_ui_zoom)) + } + pub fn view_info(&self) -> ViewInfo { ViewInfo { - zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), + // The tools measure their tolerances and widgets in the interface's points. + zoom: self.view().map_or(1.0, |v| v.screen_zoom(self.canvas_ui_zoom)), outline: self.outline_view(), smart_guides: self.ui.view.smart_guides, guides: self.ui.view.guides, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6e6806e075..202a0542a8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -98,8 +98,16 @@ pub struct Xf { } impl Xf { + /// The mapping of view `v` onto the canvas `rect`, in the points of an interface zoomed by + /// `ui_zoom` (Preferences › User Interface › UI Scaling): a document point covers the same + /// device pixels at every UI Scaling ([`View::screen_zoom`]). + pub fn at(rect: egui::Rect, v: &View, ui_zoom: f32) -> Self { + Self { rect, zoom: v.screen_zoom(ui_zoom), center: v.center, rot: v.rotation.to_radians() } + } + /// [`Self::at`] with the interface unscaled. + #[cfg(test)] pub fn new(rect: egui::Rect, v: &View) -> Self { - Self { rect, zoom: v.zoom, center: v.center, rot: v.rotation.to_radians() } + Self::at(rect, v, 1.0) } pub fn to_screen(&self, p: Point) -> Pos2 { let c = self.rect.center(); @@ -255,6 +263,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let bars = app.ui.screen_mode < 2; let (full, rect) = view_rects(app, outer, ui.ctx().pixels_per_point()); app.canvas_rect = Some(rect); + app.canvas_ui_zoom = ui.ctx().zoom_factor(); let fitted = app.view().is_some_and(|v| v.fitted); // A document's opening view is fitted to the canvas of the first frame that shows it, and the // window's layout can still move the canvas's edges over the next frames. Until the view is @@ -276,7 +285,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let resp = ui.interact(rect, egui::Id::unique("canvas"), Sense::click_and_drag()); canvas_input(app, ui, &resp, rect); let v = *app.view().unwrap_or(&View::default()); - let xf = Xf::new(rect, &v); + let xf = Xf::at(rect, &v, app.canvas_ui_zoom); // While the view pans, zooms or turns under the pointer, the art under it isn't hit-tested. let wait = hover_wait(&mut app.canvas.view_moved, ViewKey { rect, zoom: v.zoom, center: v.center, rotation: v.rotation }, ui.input(|i| i.time)); if let Some(secs) = wait { @@ -313,12 +322,14 @@ 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 && v.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); + let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).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; let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st); - let key = cache_key(app, ui, st, &t, &v, (w, h, ppp), (pixel, smooth_images, plugins)); + // The frame renders the view at its zoom in the interface's points, as `xf` maps it. + let drawn = v.on_screen(app.canvas_ui_zoom); + let key = cache_key(app, ui, st, &t, &drawn, (w, h, ppp), (pixel, smooth_images, plugins)); // Placed documents' bitmaps are being made: draw again when they are ready. if vectorcraft_render::placed_document::busy() { ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); @@ -348,7 +359,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { !waiting }); if wait.is_none() && (!same_frame || app.canvas.texture.is_none() || settle) { - let (w, h, view) = frame_view(pixel, (w, h), &v, ppp); + let (w, h, view) = frame_view(pixel, (w, h), &drawn, ppp); let opts = frame_options(app, &key, mask_view, smooth_images); draw_frame(app, ui, Frame { key: &key, doc: &doc, effects: &effects, size: (w, h), view, opts, pixel }); } @@ -446,7 +457,7 @@ fn canvas_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: // wheel, a pan, a Rotate View drag, the Zoom tool and a Presentation Mode click move the view // there. A dialog, floating panel or menu over the canvas hides it (Object Highlighting // showed through them: #4518), unless a drag that began on the canvas goes on there. - let moved = Xf::new(rect, app.view().unwrap_or(&View::default())); + let moved = Xf::at(rect, app.view().unwrap_or(&View::default()), app.canvas_ui_zoom); let dragging = ui.data(|d| d.get_temp::<Drag>(drag_id()).is_some()); app.hover_doc = ui.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p) && (dragging || drag::on_canvas(ui, resp, *p))).map(|p| moved.to_doc(p)); @@ -1208,3 +1219,5 @@ fn selection_visuals( #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_ui_scaling; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 43e5df0bcd..5f06eb980d 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -14,7 +14,7 @@ use super::{Color32, Drag, Mods, Point, PointerEvent, PointerKind, Pos2, Shape, pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { let (pointer, m, space, hover) = wheel_view(app, ui, resp, rect); let v = *app.view().unwrap_or(&View::default()); - let xf = Xf::new(rect, &v); + let xf = Xf::at(rect, &v, app.canvas_ui_zoom); // A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`), // so a tap whose press and lift come in one frame has no hover position: it presses (and // double-taps) where it was seen last (#491). @@ -227,10 +227,11 @@ fn wheel_view( // slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The // tool's events below take their document points from the view this moves to. if resp.hovered() { + let ui_zoom = app.canvas_ui_zoom; if (factor - 1.0).abs() > 1e-6 && let (Some(p), Some(vm)) = (hover, app.view_mut()) { - zoom_about(vm, rect, p, vm.zoom * factor); + zoom_about(vm, rect, p, vm.zoom * factor, ui_zoom); } if scroll != egui::Vec2::ZERO { scroll_view(app, ui, scroll); @@ -239,7 +240,7 @@ fn wheel_view( && let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view) && let Some(vm) = app.view_mut() { - rotate_about(vm, rect, p, deg); + rotate_about(vm, rect, p, deg, ui_zoom); } } (pointer, m, space, hover) @@ -328,12 +329,13 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) // Where the pointer was in the frame before: a zoom marquee moved with Space follows it. let before: Option<Pos2> = ui.data(|dd| dd.get_temp(drag_pos_id())); ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p)); + let ui_zoom = app.canvas_ui_zoom; match d { Drag::Pan { start, center, .. } => { let ppp = ui.ctx().pixels_per_point(); if let Some(vm) = app.view_mut() { vm.center = center; - pan_pixels(vm, ppp, whole_pixels(p - start, ppp)); + pan_pixels(vm, (ppp, ui_zoom), whole_pixels(p - start, ppp)); } } Drag::RotateView { start_angle, start_rot, snaps } => { @@ -360,7 +362,7 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) if exponent != 0.0 && let Some(vm) = app.view_mut() { - zoom_about(vm, rect, start, vm.zoom * exponent.exp()); + zoom_about(vm, rect, start, vm.zoom * exponent.exp(), ui_zoom); } ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved })); } @@ -446,6 +448,7 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po }); // Backtick as it was at the button-up: a key pressed or released later in the frame does not count. let backquote = backquote_at_release(ui, egui::PointerButton::Primary, f.backquote_before); + let ui_zoom = app.canvas_ui_zoom; match d { Drag::ZoomBox { start } => { let start = zoom_marquee_start(ui, start, before, p, space); @@ -456,16 +459,16 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po // The marquee's center and its size on screen set the view, at any rotation. vm.center = xf.to_doc(r.center()); let fill = (rect.width() / r.width()).min(rect.height() / r.height()); - vm.zoom = (xf.zoom * f64::from(fill)).clamp(0.0313, 640.0); + vm.zoom = (xf.zoom * f64::from(ui_zoom) * f64::from(fill)).clamp(0.0313, 640.0); } else { - zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); + zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt), ui_zoom); } } } // A click (not held, not dragged) steps the zoom as with the marquee. Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => { if let Some(vm) = app.view_mut() { - zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt)); + zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt), ui_zoom); } } Drag::ZoomScrub { .. } => {} diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index c8dc007538..fb305d29dd 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -263,11 +263,11 @@ fn refit_resized(app: &mut VectorcraftApp, areas: &[(Win, egui::Rect)]) -> Vec<b .collect() } -/// Fit `v` to show its artboard (the first if it has gone) in `rect`, as Fit Artboard in Window -/// does. -fn fit_pane(v: &mut View, doc: &vectorcraft_doc::Document, rect: egui::Rect) { +/// Fit `v` to show its artboard (the first if it has gone) in `rect`, with the interface zoomed by +/// `ui_zoom`, as Fit Artboard in Window does. +fn fit_pane(v: &mut View, doc: &vectorcraft_doc::Document, rect: egui::Rect, ui_zoom: f32) { if let Some(target) = doc.artboards.get(v.artboard).or(doc.artboards.first()).map(|a| a.rect) { - v.zoom = fit_zoom(rect, target, v.rotation); + v.zoom = fit_zoom(rect, target, v.rotation, ui_zoom); v.center = target.center(); v.fitted = true; } @@ -284,13 +284,14 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) let (full, rect) = view_rects(app, cell, ppp); let Some(st) = app.session.documents().get(w.0) else { return false }; let doc = st.doc.clone(); + let ui_zoom = ui.ctx().zoom_factor(); if let Some(v) = crate::doc_windows::view_mut(app, w).filter(|v| !v.fitted) { - fit_pane(v, &doc, rect); + fit_pane(v, &doc, rect, ui_zoom); } let v = crate::doc_windows::view(app, w).copied().unwrap_or_default(); let Some(st) = app.session.documents().get(w.0) else { return false }; let slot = (st.uid, w.1); - let xf = Xf::new(rect, &v); + let xf = Xf::at(rect, &v, ui_zoom); let painter = ui.painter_at(rect); paint_paper(app, st, ui, &painter, &xf, rect, (&t, v.outline)); let grid_look = app.ui.view.grid.then(|| LineLook::grid(&app.session.prefs)); @@ -301,15 +302,16 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) 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 && v.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); + 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 mut key = cache_key(app, ui, st, &t, &v, (pw, ph, ppp), (pixel, smooth_images, plugins)); + let drawn = v.on_screen(ui_zoom); + let mut key = cache_key(app, ui, st, &t, &drawn, (pw, ph, ppp), (pixel, smooth_images, plugins)); // The current document's drag and Image Trace hold are not this document's, and this view has // its own Outline or Preview. (key.dragging, key.trace_source, key.outline) = (false, None, v.outline); let opts = frame_options(app, &key, st.shown_mask(), smooth_images); - let (fw, fh, view) = frame_view(pixel, (pw, ph), &v, ppp); + let (fw, fh, view) = frame_view(pixel, (pw, ph), &drawn, ppp); let doc = st.doc.clone(); app.canvas.panes.draw(ui.ctx(), &painter, &xf, slot, (key, doc, ((fw, fh), view), opts)); let Some(st) = app.session.documents().get(w.0) else { return false }; diff --git a/crates/ui-egui/src/canvas/panes/tests.rs b/crates/ui-egui/src/canvas/panes/tests.rs index 91a0ca5737..9dc008a896 100644 --- a/crates/ui-egui/src/canvas/panes/tests.rs +++ b/crates/ui-egui/src/canvas/panes/tests.rs @@ -105,7 +105,7 @@ fn tile_shows_a_pane_for_each_document_fitted_to_it() { for (i, r) in docs.iter().zip(&rects) { let v = app.views[*i]; let shown = if *i == 2 { canvas } else { view_rects(&app, *r, ctx.pixels_per_point()).1 }; - assert!(v.fitted && (v.zoom - fit_zoom(shown, artboard, 0.0)).abs() < 1e-9, "document {i} fits its pane: {v:?}"); + assert!(v.fitted && (v.zoom - fit_zoom(shown, artboard, 0.0, 1.0)).abs() < 1e-9, "document {i} fits its pane: {v:?}"); assert_eq!(v.center, artboard.center()); } assert_eq!(app.canvas.panes.shown.len(), 2, "the other two documents are rendered for their panes"); @@ -115,6 +115,29 @@ fn tile_shows_a_pane_for_each_document_fitted_to_it() { assert_eq!(active, [false, false, true]); } +/// With the interface zoomed by Preferences › User Interface › UI Scaling, each tiled document +/// fits its pane with 30 interface points to spare: its zoom is the zoom that fits it in an +/// unscaled interface times the UI Scaling. +#[test] +fn tiled_documents_fit_their_panes_at_every_ui_scaling() { + for ui_zoom in [0.75_f32, 1.5, 2.0] { + let mut app = app_with(3); + let ctx = egui::Context::default(); + ctx.set_zoom_factor(ui_zoom); + let size = vec2(1200.0, 800.0) / ui_zoom; + frame_in(&mut app, &ctx, vec![], size); + app.run("window.arrange.tile", json!({})).unwrap(); + frame_in(&mut app, &ctx, vec![], size); + let (docs, rects) = panes(&app); + for (i, r) in docs.iter().zip(&rects).filter(|(i, _)| **i != 2) { + let shown = view_rects(&app, *r, ctx.pixels_per_point()).1; + let unscaled = ((f64::from(shown.width()) - 60.0) / 400.0).min((f64::from(shown.height()) - 60.0) / 300.0); + let v = app.views[*i]; + assert!(v.fitted && (v.zoom - unscaled * f64::from(ui_zoom)).abs() < 1e-9, "UI Scaling {ui_zoom}×: document {i} fits its pane: {v:?}"); + } + } +} + #[test] fn a_press_in_another_pane_makes_its_document_current_without_drawing() { let mut app = app_with(3); @@ -281,7 +304,7 @@ fn opening_a_document_while_tiled_fits_each_document_to_its_new_pane() { let pane = panes(&app).1[0]; let rect = view_rects(&app, pane, ctx.pixels_per_point()).1; let v = app.views[0]; - assert!((v.zoom - fit_zoom(rect, Rect::new(0.0, 0.0, 400.0, 300.0), 0.0)).abs() < 1e-9, "fitted to its new pane: {v:?}"); + assert!((v.zoom - fit_zoom(rect, Rect::new(0.0, 0.0, 400.0, 300.0), 0.0, 1.0)).abs() < 1e-9, "fitted to its new pane: {v:?}"); assert_ne!(v.zoom, side_by_side, "the pane changed size"); } @@ -315,7 +338,7 @@ fn a_resized_window_fits_the_tiled_documents_again_unless_one_was_zoomed() { let (_, rects) = panes(&app); for i in [0, 2] { let area = if i == 2 { app.canvas_rect.unwrap() } else { view_rects(&app, rects[i], ctx.pixels_per_point()).1 }; - assert!((app.views[i].zoom - fit_zoom(area, artboard, 0.0)).abs() < 1e-9, "document {i} fits its smaller pane: {:?}", app.views[i]); + assert!((app.views[i].zoom - fit_zoom(area, artboard, 0.0, 1.0)).abs() < 1e-9, "document {i} fits its smaller pane: {:?}", app.views[i]); } assert_eq!(app.views[1].zoom, zoomed.zoom, "the zoomed document keeps its zoom"); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 503178a22b..e01c09c1e2 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4144,11 +4144,11 @@ fn scrolls_and_pans_move_the_view_by_whole_device_pixels() { fn a_scrolls_leftover_pixel_part_carries_only_to_the_same_view() { let view = View { zoom: 1.0, center: Point::new(100.0, 100.0), fitted: true, rotation: 0.0, artboard: 0, outline: false }; let mut v = view; - let rest = scroll_by(&mut v, 1, 1.0, vec2(0.4, -0.3), ScrollRest::default()); + let rest = scroll_by(&mut v, 1, (1.0, 1.0), vec2(0.4, -0.3), ScrollRest::default()); assert_eq!(v.center, view.center, "less than half a pixel does not move the view"); assert!((rest.x - 0.4).abs() < 1e-6 && (rest.y + 0.3).abs() < 1e-6); let mut again = v; - scroll_by(&mut again, 1, 1.0, vec2(0.4, -0.3), rest); + scroll_by(&mut again, 1, (1.0, 1.0), vec2(0.4, -0.3), rest); assert_eq!(again.center, Point::new(99.0, 101.0), "the second scroll adds the first's part"); let changed = [ ("zoomed", View { zoom: 2.0, ..v }, 1, 1.0), @@ -4159,7 +4159,7 @@ fn a_scrolls_leftover_pixel_part_carries_only_to_the_same_view() { ]; for (name, before, doc, ppp) in changed { let mut after = before; - let left = scroll_by(&mut after, doc, ppp, vec2(0.4 / ppp, -0.3 / ppp), rest); + let left = scroll_by(&mut after, doc, (ppp, 1.0), vec2(0.4 / ppp, -0.3 / ppp), rest); assert_eq!(after.center, before.center, "{name}: the first scroll's part is dropped"); assert!((left.x - 0.4).abs() < 1e-6 && (left.y + 0.3).abs() < 1e-6, "{name}: {left:?}"); } diff --git a/crates/ui-egui/src/canvas/tests_ui_scaling.rs b/crates/ui-egui/src/canvas/tests_ui_scaling.rs new file mode 100644 index 0000000000..d9e2a96edf --- /dev/null +++ b/crates/ui-egui/src/canvas/tests_ui_scaling.rs @@ -0,0 +1,214 @@ +//! Preferences › User Interface › UI Scaling zooms the interface and not the art: at a given zoom +//! a document point covers the same device pixels at every UI Scaling, and the pointer lands on the +//! art drawn under it. The Navigator's view box and tiled panes have their own tests +//! (`panels::navigator`, `canvas::panes`). + +use super::*; +use vectorcraft_engine::Session; + +/// The window, in points of the screen before UI Scaling. +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`. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, (native, ui_zoom): (f32, f32), events: Vec<egui::Event>) { + 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() }; + 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(); +} + +/// A 512 × 512 pt document shown at `native` device pixels a point and UI Scaling `ui_zoom`, with a +/// controlled render worker, and the receiver of the frames it is asked to render. +fn shown(native: f32, ui_zoom: f32) -> (VectorcraftApp, egui::Context, std::sync::mpsc::Receiver<crate::render_worker::Job>) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 512, "height": 512})).unwrap(); + app.canvas.worker_started = true; + let (worker, jobs, _done) = crate::render_worker::Worker::test_channel(); + app.canvas.worker = Some(worker); + // The first frames set the UI zoom and fit the artboard. + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + assert!((ctx.pixels_per_point() - native * ui_zoom).abs() < 1e-4, "{} device pixels a point", ctx.pixels_per_point()); + (app, ctx, jobs) +} + +/// The view matrix of the last frame the canvas asked the worker to render. +fn rendered(jobs: &std::sync::mpsc::Receiver<crate::render_worker::Job>) -> Affine { + jobs.try_iter().last().expect("a frame was rendered").view +} + +/// The device pixels between two document points in the frame `view` renders. +fn device_length(view: Affine, a: Point, b: Point) -> f64 { + (view * b - view * a).hypot() +} + +/// At 100% a 128 pt edge covers 128 device pixels a point of the screen, whatever the UI Scaling, +/// in the rendered frame and in the canvas's pointer mapping; a fitted artboard draws at the zoom +/// the view reports and fits the canvas. +#[test] +fn a_document_point_covers_the_same_device_pixels_at_every_ui_scaling() { + let (a, b) = (Point::new(100.0, 100.0), Point::new(228.0, 100.0)); + for native in [1.0_f32, 2.0] { + for ui_zoom in [0.75_f32, 1.0, 1.5, 2.0] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, jobs) = shown(native, ui_zoom); + let device = |zoom: f64| 128.0 * zoom * f64::from(native); + + app.run("view.actualSize", json!({})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + assert_eq!(app.view().unwrap().zoom, 1.0, "{at}: Actual Size is 100%"); + let drawn = device_length(rendered(&jobs), a, b); + assert!((drawn - device(1.0)).abs() < 1e-6, "{at}: the frame draws 128 pt over {drawn} device pixels"); + let xf = app.canvas_xf().unwrap(); + let pointed = f64::from((xf.to_screen(b) - xf.to_screen(a)).length() * ctx.pixels_per_point()); + assert!((pointed - device(1.0)).abs() < 1e-3, "{at}: the pointer maps 128 pt to {pointed} device pixels"); + + app.run("view.fitArtboard", json!({})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let zoom = app.view().unwrap().zoom; + let view = rendered(&jobs); + let drawn = device_length(view, a, b); + assert!((drawn - device(zoom)).abs() < 1e-6, "{at}: at {zoom} the frame draws 128 pt over {drawn} device pixels"); + // The artboard fits the canvas with 30 interface points to spare on its closer sides. + let rect = app.canvas_rect.unwrap(); + let ppp = f64::from(ctx.pixels_per_point()); + let (lo, hi) = (view * Point::new(0.0, 0.0), view * Point::new(512.0, 512.0)); + let (w, h) = (f64::from(rect.width()) * ppp, f64::from(rect.height()) * ppp); + let spare = [lo.x, lo.y, w - hi.x, h - hi.y].into_iter().fold(f64::INFINITY, f64::min); + assert!((spare - 30.0 * ppp).abs() < 1.0, "{at}: {spare} device pixels to spare in {w} × {h}"); + } + } +} + +/// View › Pixel Preview shows document pixels while one is bigger than a device pixel: at 80% on +/// a screen of one device pixel a point it does not, whatever the UI Scaling. +#[test] +fn pixel_preview_compares_a_document_pixel_with_a_device_pixel() { + for (native, zoom, pixels) in [(1.0_f32, 0.8, false), (1.0, 1.25, true), (2.0, 0.8, true)] { + for ui_zoom in [0.75_f32, 1.0, 1.5, 2.0] { + let (mut app, ctx, jobs) = shown(native, ui_zoom); + app.ui.view.pixel_preview = true; + app.run("view.setZoom", json!({"zoom": zoom * 100.0})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let job = jobs.try_iter().last().expect("a frame was rendered"); + assert_eq!(job.key.pixel.is_some(), pixels, "{native} device pixels a point at {zoom}, UI Scaling {ui_zoom}×"); + } + } +} + +/// At 100% a 20 pt square 100 pt to the right of the view's center is 100 device pixels a point +/// to the right of the canvas's center: a click there selects it, the frame draws it there, and the +/// tools measure their tolerances in the interface's points, whatever the UI Scaling. +#[test] +fn a_click_at_the_true_size_position_selects_the_object_drawn_there_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 1.5), (1.0, 2.0)] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, jobs) = shown(native, ui_zoom); + app.run("view.actualSize", json!({})).unwrap(); + let c = app.view().unwrap().center; + let square = Point::new(c.x + 100.0, c.y); + let id = + app.session.execute("shape.rectangle", &json!({"x": square.x - 10.0, "y": square.y - 10.0, "width": 20, "height": 20})).unwrap()["id"] + .as_u64() + .unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let rect = app.canvas_rect.unwrap(); + let ppp = ctx.pixels_per_point(); + let p = rect.center() + vec2(100.0 * native / ppp, 0.0); + let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(p)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(true)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(false)]); + let selected = app.session.active().unwrap().selection.objects.clone(); + assert_eq!(selected, vec![vectorcraft_doc::NodeId(id)], "{at}: a click at {p:?}"); + let device = rendered(&jobs) * square; + let drawn = rect.min + vec2(device.x as f32, device.y as f32) / ppp; + assert!((drawn - p).length() * ppp < 1.0, "{at}: drawn at {drawn:?}, not {p:?}"); + assert!((app.view_info().zoom - 1.0 / f64::from(ui_zoom)).abs() < 1e-9, "{at}: the tools' zoom is {}", app.view_info().zoom); + } +} + +/// A pinch zooms about the pointer: the document point under the pointer stays under it, whatever +/// the UI Scaling. +#[test] +fn a_pinch_zooms_about_the_pointer_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 2.0)] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, _jobs) = shown(native, ui_zoom); + let rect = app.canvas_rect.unwrap(); + let p = rect.center() + vec2(150.0, -90.0) / ui_zoom; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(p)]); + let (before, z0) = (app.canvas_xf().unwrap().to_doc(p), app.view().unwrap().zoom); + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(p), egui::Event::Zoom(1.25)]); + for _ in 0..4 { + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + } + let z1 = app.view().unwrap().zoom; + assert!(z1 > z0 * 1.01, "{at}: the pinch zooms in, {z0} → {z1}"); + let after = app.canvas_xf().unwrap().to_doc(p); + let moved = (after - before).hypot() * app.view().unwrap().screen_zoom(ui_zoom) * f64::from(ctx.pixels_per_point()); + assert!(moved < 1e-3, "{at}: {before:?} under the pointer moved {moved} device pixels to {after:?}"); + } +} + +/// With Animated Zoom off, the Zoom tool zooms to the area dragged across: the zoom grows by the +/// times the marquee fits in the canvas, and the marquee's area then fills the canvas, whatever +/// the UI Scaling. +#[test] +fn the_zoom_tools_marquee_fills_the_canvas_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 2.0)] { + let at = format!("{native} device pixels a point, UI Scaling {ui_zoom}×"); + let (mut app, ctx, _jobs) = shown(native, ui_zoom); + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + app.select_tool("zoom"); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let rect = app.canvas_rect.unwrap(); + let (from, to) = (rect.center() - vec2(60.0, 20.0), rect.center() + vec2(40.0, 30.0)); + let xf = app.canvas_xf().unwrap(); + let (a, b, z0) = (xf.to_doc(from), xf.to_doc(to), app.view().unwrap().zoom); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(from)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(from, true)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(to)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![button(to, false)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![]); + let fill = f64::from((rect.width() / 100.0).min(rect.height() / 50.0)); + let z1 = app.view().unwrap().zoom; + assert!((z1 - z0 * fill).abs() < 1e-6, "{at}: {z0} → {z1}, not {}", z0 * fill); + let xf = app.canvas_xf().unwrap(); + let shown = xf.to_screen(b) - xf.to_screen(a); + let filled = (shown.x - rect.width()).abs().min((shown.y - rect.height()).abs()); + assert!(filled < 1e-2, "{at}: the marquee's area is {shown:?} in a {:?} canvas", rect.size()); + } +} + +/// A middle-button drag moves the art with the pointer: the document point under the press is +/// under the release, whatever the UI Scaling. +#[test] +fn a_pan_moves_the_art_with_the_pointer_at_every_ui_scaling() { + for (native, ui_zoom) in [(1.0_f32, 0.75_f32), (1.0, 1.5), (2.0, 2.0)] { + let (mut app, ctx, _jobs) = shown(native, ui_zoom); + let rect = app.canvas_rect.unwrap(); + let (from, to) = (rect.center(), rect.center() + vec2(60.0, -40.0)); + let pressed = app.canvas_xf().unwrap().to_doc(from); + let middle = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(from)]); + frame(&mut app, &ctx, (native, ui_zoom), vec![middle(from, true)]); + for k in 1..=4 { + frame(&mut app, &ctx, (native, ui_zoom), vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))]); + } + frame(&mut app, &ctx, (native, ui_zoom), vec![middle(to, false)]); + let released = app.canvas_xf().unwrap().to_doc(to); + let ppp = f64::from(ctx.pixels_per_point()); + let zoom = app.view().unwrap().screen_zoom(ui_zoom); + assert!( + (released - pressed).hypot() * zoom * ppp < 1.0, + "{native} device pixels a point, UI Scaling {ui_zoom}×: {pressed:?} is now under the pointer as {released:?}" + ); + } +} diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 60cb6687b4..dae1060502 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -33,7 +33,7 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) { let zoom = if how == "view.actualSize" { actual_size_zoom(app.session.prefs.display_print_size) } else { - fit_zoom(rect, target, app.view().map_or(0.0, |v| v.rotation)) + fit_zoom(rect, target, app.view().map_or(0.0, |v| v.rotation), app.canvas_ui_zoom) }; let Some(v) = app.view_mut() else { return }; v.center = target.center(); @@ -41,15 +41,15 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) { v.zoom = zoom; } -/// The zoom at which `target`, turned `rotation` degrees with the view, fits in `rect` with a -/// 30-point margin on each side. -pub(super) fn fit_zoom(rect: egui::Rect, target: Rect, rotation: f64) -> f64 { +/// The zoom at which `target`, turned `rotation` degrees with the view, fits in `rect` (points of +/// an interface zoomed by `ui_zoom`) with a 30-point margin on each side. +pub(super) fn fit_zoom(rect: egui::Rect, target: Rect, rotation: f64, ui_zoom: f32) -> f64 { // The width and height of the box round the target turned with the view, at zoom 1. let (sn, cs) = rotation.to_radians().sin_cos(); let (w, h) = (target.width(), target.height()); let zx = (rect.width() as f64 - 60.0) / (w * cs.abs() + h * sn.abs()).max(1.0); let zy = (rect.height() as f64 - 60.0) / (w * sn.abs() + h * cs.abs()).max(1.0); - zx.min(zy).clamp(0.0313, 640.0) + (zx.min(zy) * f64::from(ui_zoom)).clamp(0.0313, 640.0) } /// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`). @@ -110,26 +110,27 @@ pub(crate) fn actual_size_zoom(display_print_size: bool) -> f64 { } /// Zoom view `vm` of the canvas `rect` to `zoom` (clamped to the zoom range), keeping the document -/// point under screen point `p` where it is. -pub(super) fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64) { - let before = Xf::new(rect, vm).to_doc(p); +/// point under screen point `p` where it is, with the interface zoomed by `ui_zoom`. +pub(super) fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64, ui_zoom: f32) { + let before = Xf::at(rect, vm, ui_zoom).to_doc(p); vm.zoom = zoom.clamp(0.0313, 640.0); - vm.center += before - Xf::new(rect, vm).to_doc(p); + vm.center += before - Xf::at(rect, vm, ui_zoom).to_doc(p); } /// Turn view `vm` of the canvas `rect` by `degrees` clockwise, keeping the document point under -/// screen point `p` where it is. -pub(super) fn rotate_about(vm: &mut View, rect: egui::Rect, p: Pos2, degrees: f64) { - let before = Xf::new(rect, vm).to_doc(p); +/// screen point `p` where it is, with the interface zoomed by `ui_zoom`. +pub(super) fn rotate_about(vm: &mut View, rect: egui::Rect, p: Pos2, degrees: f64, ui_zoom: f32) { + let before = Xf::at(rect, vm, ui_zoom).to_doc(p); vm.rotation = vectorcraft_geom::normalize_deg(vm.rotation + degrees); - vm.center += before - Xf::new(rect, vm).to_doc(p); + vm.center += before - Xf::at(rect, vm, ui_zoom).to_doc(p); } /// Move view `vm`'s content by `(dx, dy)` device pixels along the screen's axes, at `ppp` device -/// pixels a point: the canvas's frame moves by exactly those pixels. -pub(super) fn pan_pixels(vm: &mut View, ppp: f32, (dx, dy): (f64, f64)) { +/// pixels a point of an interface zoomed by `ui_zoom`: the canvas's frame moves by exactly those +/// pixels. +pub(super) fn pan_pixels(vm: &mut View, (ppp, ui_zoom): (f32, f32), (dx, dy): (f64, f64)) { let (sn, cs) = vm.rotation.to_radians().sin_cos(); - let k = vm.zoom * f64::from(ppp); + let k = vm.screen_zoom(ui_zoom) * f64::from(ppp); vm.center -= vectorcraft_geom::Vec2::new((dx * cs + dy * sn) / k, (-dx * sn + dy * cs) / k); } @@ -155,20 +156,21 @@ pub(super) struct ScrollRest { /// of another kind, another document) or the device 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 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 }; let id = egui::Id::unique("canvas-scroll-rest"); - let rest = scroll_by(vm, doc, ppp, by, ui.data(|d| d.get_temp(id)).unwrap_or_default()); + let rest = scroll_by(vm, doc, (ppp, ui_zoom), by, ui.data(|d| d.get_temp(id)).unwrap_or_default()); ui.data_mut(|d| d.insert_temp(id, rest)); } -/// [`scroll_view`] for view `vm` of document `doc` at `ppp` device pixels a point, after the -/// scrolls that left `rest`: what this scroll leaves. -pub(super) fn scroll_by(vm: &mut View, doc: u64, ppp: f32, by: egui::Vec2, rest: ScrollRest) -> ScrollRest { +/// [`scroll_view`] for view `vm` of document `doc` at `ppp` device pixels a point of an interface +/// zoomed by `ui_zoom`, after the scrolls that left `rest`: what this scroll leaves. +pub(super) fn scroll_by(vm: &mut View, doc: u64, (ppp, ui_zoom): (f32, f32), by: egui::Vec2, rest: ScrollRest) -> ScrollRest { let (x, y) = if rest.after == Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) { (rest.x, rest.y) } else { (0.0, 0.0) }; let (x, y) = (x + f64::from(by.x) * f64::from(ppp), y + f64::from(by.y) * f64::from(ppp)); let (dx, dy) = (x.round(), y.round()); - pan_pixels(vm, ppp, (dx, dy)); + pan_pixels(vm, (ppp, ui_zoom), (dx, dy)); ScrollRest { x: x - dx, y: y - dy, after: Some((doc, vm.center, vm.zoom, vm.rotation, ppp)) } } @@ -279,7 +281,8 @@ pub(super) fn scroll_tracks(rect: egui::Rect, outer: egui::Rect) -> [egui::Rect; pub(super) fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, outer: egui::Rect, t: &Tokens) { let content = app.content_bounds(); let Some(v) = app.view().copied() else { return }; - let xf = Xf::new(rect, &v); + let ui_zoom = app.canvas_ui_zoom; + let xf = Xf::at(rect, &v, ui_zoom); let painter = ui.painter(); painter.rect_filled(egui::Rect::from_min_max(outer.max - vec2(SCROLL_BAR, SCROLL_BAR), outer.max), 0.0, t.ruler); let hover = ui.input(|i| i.pointer.hover_pos()); @@ -346,7 +349,7 @@ pub(super) fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, o && let Some(vm) = app.view_mut() { vm.center = c; - pan_pixels(vm, ppp, whole_pixels(-by, ppp)); + pan_pixels(vm, (ppp, ui_zoom), whole_pixels(-by, ppp)); } if moved != egui::Vec2::ZERO { scroll_view(app, ui, -moved); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 11edb6de6e..1e3abd6592 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -870,7 +870,7 @@ fn rotate_view_pointer( None => { let rect = app.canvas_rect.ok_or("no canvas yet")?; let view = app.view().ok_or("no document")?; - RotateViewPress { window, document_resets, xf: Xf::new(rect, view), start_angle: 0.0, start_rotation: view.rotation } + RotateViewPress { window, document_resets, xf: Xf::at(rect, view, app.canvas_ui_zoom), start_angle: 0.0, start_rotation: view.rotation } } }; // A gesture cannot turn a different document/view or resume after its document was reset. @@ -975,7 +975,7 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con Err(e) => return err(e), }; let view = app.view_info(); - let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); + let xf = app.canvas_xf(); let screen = |e: &Value| e.get("space").and_then(Value::as_str) == Some("screen"); if !rotating && xf.is_none() && events.iter().any(screen) { return err("no canvas yet"); diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs index c4c043b39e..63dcfa916b 100644 --- a/crates/ui-egui/src/dialogs/gradient_stop.rs +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -92,9 +92,8 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0)); let area = egui::Id::unique("gradient-stop-popover"); let size = ctx.memory(|m| m.area_rect(area)).map_or(POPOVER_SIZE, |r| r.size()); - let pos = screen.unwrap_or_else(|| match app.canvas_rect.zip(app.view().copied()) { - Some((canvas, v)) => { - let xf = Xf::new(canvas, &v); + let pos = screen.unwrap_or_else(|| match app.canvas_rect.zip(app.canvas_xf()) { + Some((canvas, xf)) => { let at = xf.to_screen(chip); selection_on_screen(app, &xf).map_or(at + vec2(14.0, 14.0), |art| beside_art(at, art, size, canvas)) } diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index 5016eacd06..4595447ac2 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -92,7 +92,7 @@ impl Geometry { let state = app.session.active()?; let node = state.doc.node(*state.selection.objects.first()?)?; let bounds = node.geometric_bounds()?; - let xf = Xf::new(app.canvas_rect?, app.view()?); + let xf = app.canvas_xf()?; let options = super::revolve::light_options(d); // Both Revolves turn about their axis; the others about the centre of the art's bounds. let id = d.str("__effect"); diff --git a/crates/ui-egui/src/dialogs/simplify_slider.rs b/crates/ui-egui/src/dialogs/simplify_slider.rs index 820ab9af90..88838585ee 100644 --- a/crates/ui-egui/src/dialogs/simplify_slider.rs +++ b/crates/ui-egui/src/dialogs/simplify_slider.rs @@ -202,8 +202,7 @@ fn bounds(app: &mut VectorcraftApp, d: &mut Dialog) -> Option<Rect> { fn place(app: &mut VectorcraftApp, ctx: &egui::Context, id: egui::Id, bounds: Option<Rect>) -> Pos2 { let size = egui::AreaState::load(ctx, id).and_then(|s| s.size).unwrap_or(vec2(270.0, 42.0)); let screen = ctx.content_rect(); - let Some((rect, view)) = app.canvas_rect.zip(app.view().copied()) else { return screen.center() - size / 2.0 }; - let xf = crate::canvas::Xf::new(rect, &view); + let Some((rect, xf)) = app.canvas_rect.zip(app.canvas_xf()) else { return screen.center() - size / 2.0 }; let under = match bounds { Some(b) => { let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index be668e1e4f..4a1888337e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -197,6 +197,10 @@ pub struct VectorcraftApp { frame_rate: frame_rate::FrameRate, /// Canvas rect of the last frame (screen points), for control-channel coordinate mapping. pub canvas_rect: Option<egui::Rect>, + /// The UI zoom (egui's zoom factor, set by Preferences › User Interface › UI Scaling) of the + /// frame that laid out [`Self::canvas_rect`]; 1.0 before the first one. [`Self::canvas_xf`] + /// maps the document to that rect with it. + pub(crate) canvas_ui_zoom: f32, /// An unfinished control-channel Rotate View gesture, kept only for this app instance. pub(crate) control_rotate_press: Option<control::RotateViewPress>, /// Hover position in document coordinates. diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index f435bfff22..f15ff97744 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -405,7 +405,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "view.actualSize", "Actual Size", "Cmd+1", - "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling)", + "{} 100%: with Display Print Size at 100% Zoom off, one document point per screen point; on, one document inch fills 96 screen points (an inch at the system's reference density, whatever the display scaling); the same at every UI Scaling", ), ("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"), ( diff --git a/crates/ui-egui/src/panels/navigator.rs b/crates/ui-egui/src/panels/navigator.rs index da8048407b..60eb4c18d0 100644 --- a/crates/ui-egui/src/panels/navigator.rs +++ b/crates/ui-egui/src/panels/navigator.rs @@ -249,11 +249,12 @@ pub fn thumb_to_doc(region: DRect, thumb: Rect, p: egui::Pos2) -> Point { Point::new(region.x0 + (p.x as f64 - ox) / s, region.y0 + (p.y as f64 - oy) / s) } -/// The view box in the thumbnail: the corners of the canvas `canvas` showing view `v`, mapped -/// into the document (turned with the view) and then into the thumbnail, cut to the thumbnail -/// grown by a point. Fewer than three points when the view is outside the thumbnail. -fn view_box(region: DRect, thumb: Rect, canvas: Rect, v: &crate::state::View) -> Vec<egui::Pos2> { - let xf = crate::canvas::Xf::new(canvas, v); +/// The view box in the thumbnail: the corners of the canvas `canvas` showing view `v` (with the +/// interface zoomed by `ui_zoom`), mapped into the document (turned with the view) and then into +/// the thumbnail, cut to the thumbnail grown by a point. Fewer than three points when the view is +/// outside the thumbnail. +fn view_box(region: DRect, thumb: Rect, canvas: Rect, v: &crate::state::View, ui_zoom: f32) -> Vec<egui::Pos2> { + let xf = crate::canvas::Xf::at(canvas, v, ui_zoom); let mut pts: Vec<egui::Pos2> = [canvas.left_top(), canvas.right_top(), canvas.right_bottom(), canvas.left_bottom()] .iter() .map(|c| doc_to_thumb(region, thumb, xf.to_doc(*c))) @@ -330,7 +331,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // The view box: the canvas's corners in the document, turned with the view, cut to the thumbnail. let view = app.view().copied(); if let (Some(v), Some(cr)) = (view, app.canvas_rect) { - let box_points = view_box(region, thumb, cr, &v); + let box_points = view_box(region, thumb, cr, &v, app.canvas_ui_zoom); if box_points.len() > 2 { let color = app.ui.navigator_panel.view_box(); ui.painter().with_clip_rect(thumb).add(egui::Shape::closed_line(box_points, Stroke::new(1.5, color))); @@ -449,6 +450,54 @@ mod tests { assert!(points.iter().zip(&want).all(|(a, b)| a.distance(*b) < 0.5), "{points:?} is not {want:?}"); } + /// With the interface zoomed by Preferences › User Interface › UI Scaling, the view box still + /// outlines what the canvas shows: at 400% and UI Scaling 1.5×, a canvas 600 interface points + /// wide shows 600 × 1.5 / 4 document points. + #[test] + fn the_view_box_outlines_the_canvas_at_every_ui_scaling() { + use vectorcraft_engine::Session; + for ui_zoom in [0.75_f32, 1.5, 2.0] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let canvas = Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)); + app.canvas_rect = Some(canvas); + app.canvas_ui_zoom = ui_zoom; + app.run("view.setZoom", json!({"zoom": 400, "center": [200, 150]})).unwrap(); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)); + out.textures_delta.clear(); + let pasteboard = Tokens::get(&ctx).pasteboard; + let thumb = out + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.fill == pasteboard => Some(r.rect), + _ => None, + }) + .expect("the thumbnail"); + let red = egui::epaint::ColorMode::Solid(Color32::from_rgb(0xff, 0x30, 0x30)); + let drawn: Vec<Vec<egui::Pos2>> = out + .shapes + .into_iter() + .filter_map(|c| match c.shape { + egui::Shape::Path(p) if p.closed && p.stroke.color == red => Some(p.points), + _ => None, + }) + .collect(); + let [points] = &drawn[..] else { panic!("UI Scaling {ui_zoom}×: one view box: {drawn:?}") }; + let region = CACHE.with(|c| c.borrow().region.unwrap().1); + // Each canvas corner is (its offset from the canvas's center) × UI Scaling / zoom + // document points from the view's center. + let k = f64::from(ui_zoom) / 4.0; + let want = [canvas.left_top(), canvas.right_top(), canvas.right_bottom(), canvas.left_bottom()].map(|c| { + let d = c - canvas.center(); + doc_to_thumb(region, thumb, Point::new(200.0 + f64::from(d.x) * k, 150.0 + f64::from(d.y) * k)) + }); + assert_eq!(points.len(), 4, "UI Scaling {ui_zoom}×: {points:?}"); + assert!(points.iter().zip(&want).all(|(a, b)| a.distance(*b) < 0.5), "UI Scaling {ui_zoom}×: {points:?} is not {want:?}"); + } + } + /// The panel menu's Panel Options… is enabled, and a click opens the Navigator Panel Options. #[test] fn panel_options_opens_from_the_panel_menu() { diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 6a984a1ced..c7b8e47828 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -346,7 +346,7 @@ fn place_above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, spa /// Screen points per point of text `n` at the canvas zoom, as [`above_text`] sizes the ruler: /// across the frame for area type, the zoom for point type. fn screen_scale(app: &VectorcraftApp, n: &Node, span: f64) -> Option<f64> { - let xf = crate::canvas::Xf::new(app.canvas_rect?, app.view()?); + let xf = app.canvas_xf()?; let per = match &n.kind { NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => f64::from(xf.rect_to_screen(n.geometric_bounds()?).width()) / span, _ => xf.zoom, @@ -358,7 +358,7 @@ fn screen_scale(app: &VectorcraftApp, n: &Node, span: f64) -> Option<f64> { /// put 0 on the text's left edge and its marks at the canvas zoom, so each stop sits over the /// column it sets (#729). `span`: the ruler's length in the text's points. fn above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, span: f64) -> Option<()> { - let xf = crate::canvas::Xf::new(app.canvas_rect?, app.view()?); + let xf = app.canvas_xf()?; let on_screen = xf.rect_to_screen(n.geometric_bounds()?); // Screen points per point of the text: across the frame for area type (so a scaled frame // lines up too), the zoom for point type. diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index de9407e2f3..1169422789 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -451,7 +451,7 @@ impl VectorcraftApp { let at = match pos { _ if io::opens_as(name).is_some() || fileio::opens_with_importer(name) => return DropTarget::Open, Some(p) if !rect.contains(p) => return DropTarget::Open, - Some(p) => crate::canvas::Xf::new(rect, v).to_doc(p), + Some(p) => crate::canvas::Xf::at(rect, v, self.canvas_ui_zoom).to_doc(p), None if is_document(name) => return DropTarget::Open, None => v.center, }; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4a0e73da6b..ec026573d0 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -9,7 +9,9 @@ use crate::theme::Brightness; /// Per-document view (camera). #[derive(Clone, Copy, Debug, Serialize, Deserialize)] pub struct View { - /// Screen points per document point (1.0 = 100%). + /// The zoom (1.0 = 100%): points of the screen per document point, counted before + /// Preferences › User Interface › UI Scaling, which scales the interface and not the art. + /// [`Self::screen_zoom`] is the zoom in the interface's points. pub zoom: f64, /// Document point at the centre of the canvas. pub center: Point, @@ -55,6 +57,19 @@ impl View { outline: false, }) } + + /// The interface's points per document point when the interface is zoomed by `ui_zoom` (egui's + /// zoom factor, which Preferences › User Interface › UI Scaling sets). A document point then + /// covers the same device pixels at every UI Scaling. + pub fn screen_zoom(&self, ui_zoom: f32) -> f64 { + self.zoom / f64::from(ui_zoom) + } + + /// This view with its zoom in the interface's points ([`Self::screen_zoom`]), as the canvas + /// renders it. + pub(crate) fn on_screen(&self, ui_zoom: f32) -> Self { + Self { zoom: self.screen_zoom(ui_zoom), ..*self } + } } /// The status bar's angle menu and View › Rotate View list these preset view angles, each as diff --git a/docs/mcp.md b/docs/mcp.md index fa22c66414..47ed67641a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5033,7 +5033,8 @@ control channel's `ui.drag` scrubs), `showHomeScreen` (on by default: the Home s off, an empty window, and the Home button or `app.home` still shows the screen), `autoCollapseIconPanels` (off by default: on, a click away from a panel popped out of the icon column puts it away), `displayPrintSize` (off by default: View › Actual Size / `view.actualSize` is one document point per screen point; on, one document inch fills -96 screen points, an inch at the system's reference density whatever the display scaling). The tool shortcut +96 screen points, an inch at the system's reference density whatever the display scaling). `uiScaling` scales the +interface and not the art: a zoom shows the art at the same size at every UI Scaling. The tool shortcut preference `holdToolKeys` also needs the desktop app. It is on by default: a single-key tool shortcut held for 0.25 s or more, or held while its tool is used, chooses the tool until the key is let go and then returns to the tool active before, after any drag in progress; a quicker press chooses the tool for good. With it off, every press and every key diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d42a4d988b..3e63245e78 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -870,6 +870,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Units › East Asian Type (the bullet below). Then User Interface › Open Documents As Tabs (see Window › Arrange). Three stored options still do nothing yet (the audit below). - Preferences › General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default; dimmed while Zoom with Mouse Wheel is on): while Zoom with Mouse Wheel is off, Cmd/Ctrl with a mouse wheel's whole notches scrolls the canvas sideways instead of zooming. Alt + Cmd/Ctrl-wheel and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer, and once a trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. Under either wheel preference, Cmd/Ctrl with a touchpad zooms about the pointer. - Preferences › Type › Highlight Substituted Fonts (on by default) paints pink behind text drawn in a substitute for a missing font in every document, and turning it off removes that highlight everywhere (`prefs.set highlightSubstitutedFonts`); Document Setup keeps Highlight Substituted Glyphs ([help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/edit-text-without-replacing-missing-fonts.html)). + - Preferences › User Interface › UI Scaling scales the interface ([help](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/modify-workspaces.html)). VectorCraft's own rule is that it leaves the art's size alone: at a given zoom a document point covers the same device pixels at every UI Scaling, so View › Actual Size shows the art at the same size whatever the UI Scaling. A click, a pinch or wheel zoom about the pointer, a pan, the Zoom tool's marquee, Fit, tiled panes, Pixel Preview and the Navigator's view box use the same mapping. Evidence: `canvas::tests_ui_scaling`, `tiled_documents_fit_their_panes_at_every_ui_scaling`, `the_view_box_outlines_the_canvas_at_every_ui_scaling`. Scores unchanged. - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only (Units › East Asian Type and User Interface › Open Documents As Tabs, stored only on the day of the audit, act now: East Asian Type since 2026-10-31, Open Documents As Tabs as the Window › Arrange bullet says); [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. - Units › East Asian Type (#394, `unitsAsianType`): Points by default; it offers every unit and Q and Ha, a quarter of a millimetre each (0.709 pt: the [Illustrator Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/); `q` and `h` are the field suffixes [InDesign's notation table](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html) lists), and only it lists them: rulers, Document Setup and the other Units preferences keep their ten. The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. The model stays in points; any length field reads `13q`/`13h`. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` (`crates/engine/src/tests/units.rs`). - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except User Interface › Open Documents As Tabs, Units › East Asian Type, Hyphenation › Default Language, Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only; [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. From f18fb4e92ded4e5c4e8db80d183198365ba79fe9 Mon Sep 17 00:00:00 2001 From: ruchirguitar <info@jeevantirth.org> Date: Thu, 12 Nov 2026 20:56:16 +0530 Subject: [PATCH 76/79] PDF import: Indic type that would not draw as the file does keeps the file's glyphs (#5235) Gujarati text from a PDF came in jumbled even with its fonts installed: - A glyph with no advance (a vowel sign or anusvara set over its letter) was taken for vertical type and opened as an object of its own, which type then drew on a dotted circle. In a font the file writes horizontally it now stays on its line. - A line read glyph by glyph is not always the text that was set: the file draws a vowel sign before the consonant it follows, and may not name a conjunct. A line with characters of U+0900 to U+0DFF is now set again and its glyphs compared with the ink the file drew; when they differ the line keeps the file's glyphs as outlines, with a note. Tests: crates/pdf/src/tests/reshaped.rs (two fail without the change). --- crates/pdf/src/import.rs | 5 +- crates/pdf/src/import/builder.rs | 89 +++++++++++++++++++--- crates/pdf/src/import/device.rs | 3 +- crates/pdf/src/import/entry.rs | 12 ++- crates/pdf/src/import/reshaped.rs | 53 +++++++++++++ crates/pdf/src/import/state.rs | 4 + crates/pdf/src/import_text.rs | 6 ++ crates/pdf/src/lib.rs | 4 +- crates/pdf/src/tests.rs | 1 + crates/pdf/src/tests/reshaped.rs | 119 ++++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- 11 files changed, 282 insertions(+), 17 deletions(-) create mode 100644 crates/pdf/src/import/reshaped.rs create mode 100644 crates/pdf/src/tests/reshaped.rs diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index ffc185a575..d978680d67 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -35,6 +35,7 @@ mod builder; mod cid; mod device; mod entry; +mod reshaped; mod state; #[cfg(test)] @@ -42,8 +43,8 @@ pub(crate) use cid::{CidText, font_chars, identify_glyph, unify_radical}; #[cfg(any(test, feature = "read-count"))] pub use entry::imports_of; pub use entry::{ - INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, - import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, + INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, RESHAPED_TEXT_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, + holds_outlines, import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, }; #[cfg(test)] pub(crate) use entry::{TEST_MAX_OBJECTS, import_with_reader_options, read}; diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 945c082a26..6d47168716 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -1,9 +1,10 @@ use super::cid::{CidText, FontInfo, comparable, font_chars, identify_glyph, matching_face, replacement, unify_radical}; use super::entry::{ - MAX_NESTED, OVERPRINT_UNREAD, OVERPRINT_ZERO_MODE, PATTERN_REUSE, TEXT_OUTLINES, TOO_MANY_OBJECTS, UNMATCHED_GLYPHS_NOTE, + MAX_NESTED, OVERPRINT_UNREAD, OVERPRINT_ZERO_MODE, PATTERN_REUSE, RESHAPED_TEXT_NOTE, TEXT_OUTLINES, TOO_MANY_OBJECTS, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, max_objects, }; -use super::state::{Builder, Frame, FrameKind, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer}; +use super::reshaped; +use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer}; use super::{ Affine, Appearance, AppearanceItem, Arc, BezPath, BfString, BlendMode, CacheKey, Color, Colors, Families, FillLayer, FillRule, FunctionBudget, FunctionPaint, HashMap, HashSet, ImageBlob, ImageObject, Knockout, Look, MaskSpec, MaskType, Native, Node, NodeId, NodeKind, OpacityMask, Paint, @@ -22,6 +23,7 @@ impl<'p> Builder<'p> { page: Rect::ZERO, glyphs: None, text: None, + text_glyphs: vec![], text_as, actual: Default::default(), images: HashMap::new(), @@ -101,6 +103,7 @@ impl<'p> Builder<'p> { self.blend = BlendMode::Normal; self.glyphs = None; self.text = None; + self.text_glyphs.clear(); self.mask = None; self.masked.clear(); self.marked.clear(); @@ -376,18 +379,56 @@ impl<'p> Builder<'p> { } pub(super) fn flush_text(&mut self) { - let Some(mut line) = self.text.take() else { return }; + if self.end_text() { + self.flush_glyphs(); + } + } + + /// Finish the open line of type. `true`: it was kept as outlines instead ([`reshaped`]), which + /// are left open as the glyph run for an outline drawn next to join. + pub(super) fn end_text(&mut self) -> bool { + let glyphs = std::mem::take(&mut self.text_glyphs); + let Some(mut line) = self.text.take() else { return false }; let held = std::mem::take(&mut line.held); + // Type the file names itself (ActualText) is in reading order as it stands. + let checked = !glyphs.is_empty() && line.mapped(); + let mut outlined = false; if let Some((t, opacity, facts)) = line.finish() { - let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t))); - n.opacity = opacity; - self.lines.insert(n.id, facts); - self.push_node(n); + if checked && reshaped::reshapes(&t.plain_text()) && !reshaped::draws_as(&t, &facts.ink) { + outlined = true; + for g in glyphs { + let same = self.glyphs.as_ref().is_some_and(|r| { + r.paint == g.paint + && r.opacity == g.opacity + && r.overprint == g.overprint + && r.stroke.is_some() == g.stroke.is_some() + && r.stroke.as_ref().map(|s| s.line_width) == g.stroke.as_ref().map(|s| s.line_width) + && (r.scale - g.scale).abs() < 1e-9 + }); + match &mut self.glyphs { + Some(r) if same => r.path.extend(g.path.iter()), + _ => { + self.flush_glyphs(); + self.glyphs = Some(g); + } + } + } + self.warn(RESHAPED_TEXT_NOTE); + } else { + let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t))); + n.opacity = opacity; + self.lines.insert(n.id, facts); + self.push_node(n); + } + } + if outlined && !held.is_empty() { + self.flush_glyphs(); } // The strokes of its glyphs' outlines, right after it ([`import_lines`] makes them its stroke). for n in held { self.push_node(n); } + outlined } /// A stroke of the outline of the glyph just drawn, in the glyph's own paint, while every glyph @@ -737,6 +778,27 @@ impl<'p> Builder<'p> { n } + /// Glyph `o` of the open line of type, placed by `m` and drawn with `drawn` (its paint, and its + /// stroke when it is stroked), as the outline it is kept as if the line is + /// ([`Self::end_text`]). Nothing for a glyph that shows nothing. + fn keep_glyph( + &mut self, + o: &craft_pdf::interpret::font::OutlineGlyph, + m: Affine, + drawn: Option<(Paint, Option<StrokeProps>)>, + opacity: f32, + scale: f64, + overprint: bool, + ) { + let Some((paint, stroke)) = drawn else { return }; + let mut path = o.outline(); + if path.elements().is_empty() { + return; + } + path.apply_affine(m); + self.text_glyphs.push(GlyphRun { path, paint, opacity, stroke, scale, overprint }); + } + /// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps /// its outline (no Unicode text, mirrored, or not type mode). No `paint`: invisible text, /// type with neither fill nor stroke. @@ -794,8 +856,11 @@ impl<'p> Builder<'p> { return false; } let width = o.advance_width().filter(|w| w.is_finite()); - // Set vertically (WMode 1): no horizontal advance. - let upright = width.is_some_and(|w| w.abs() <= 1.0); + // Set vertically (WMode 1): no horizontal advance. Not in a font the file says is written + // horizontally: there a glyph without an advance is a mark over the letter before it (a + // vowel sign, an anusvara), which stays on its line (#5235). + let horizontal = self.fonts.get(&key).is_some_and(|f| f.vertical.is_none()); + let upright = !horizontal && width.is_some_and(|w| w.abs() <= 1.0); let mut info = self.font_name(key, o); // A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which // style of the installed family it is, so the name decides. @@ -841,6 +906,8 @@ impl<'p> Builder<'p> { } None => (None, 1.0), }; + // What the glyph is drawn with as an outline, should its line be kept so. + let drawn = paint.clone().map(|p| (p, stroke.cloned())); let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale))); let look = Look { font: key, @@ -879,6 +946,7 @@ impl<'p> Builder<'p> { && look.fill.is_none() && line.stroke_last(key, at, s.clone(), overprint) { + self.keep_glyph(o, m, drawn, opacity, scale, overprint); return true; } if !self.text.as_mut().is_some_and(place) { @@ -889,6 +957,9 @@ impl<'p> Builder<'p> { } // Its ink: a stroke the file draws as outlines of the glyphs is told by it. let mut ink = o.outline(); + if top.is_none() { + self.keep_glyph(o, m, drawn, opacity, scale, overprint); + } if let Some(line) = &mut self.text && !ink.elements().is_empty() { diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index c21e51dbb5..b39905d6e0 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -191,7 +191,8 @@ impl<'a> Device<'a> for Builder<'_> { if self.type_glyph(o, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) { return; } - self.flush_text(); + // A line kept as outlines leaves them open: this glyph joins them. + self.end_text(); let mut bp = o.outline(); bp.apply_affine(transform * glyph_transform); let (paint, opacity) = self.paint(paint, stroke.is_some(), self.walked(true)); diff --git a/crates/pdf/src/import/entry.rs b/crates/pdf/src/import/entry.rs index 38f22689a8..8503b4fb20 100644 --- a/crates/pdf/src/import/entry.rs +++ b/crates/pdf/src/import/entry.rs @@ -171,14 +171,22 @@ pub const INVISIBLE_TEXT_NOTE: &str = "invisible text (such as a scanned page's /// Warning: glyphs that differ from the installed font of their name were kept as outlines. pub const UNMATCHED_GLYPHS_NOTE: &str = "text whose glyphs differ from the installed font of the same name was kept as outlines"; +/// Warning: type in a script whose letters reorder and join (Indic scripts) that, set again from +/// its characters, wouldn't draw the glyphs where the file drew them was kept as outlines (#5235). +pub const RESHAPED_TEXT_NOTE: &str = + "text that would not look as it does in the file if set as type (a script whose letters join) was kept as outlines"; + /// The end of the warning that glyphs whose characters the file doesn't name were kept as /// outlines, after "text in" and the font's name. pub const UNNAMED_CHARACTERS_NOTE: &str = "whose characters the file doesn't name (U+FFFD) was kept as outlines"; /// Is `note` one of the warnings that glyphs were kept as outlines ([`OUTLINED_TEXT_NOTE`], -/// [`UNMATCHED_GLYPHS_NOTE`], [`UNNAMED_CHARACTERS_NOTE`])? +/// [`UNMATCHED_GLYPHS_NOTE`], [`RESHAPED_TEXT_NOTE`], [`UNNAMED_CHARACTERS_NOTE`])? pub fn is_outlines_note(note: &str) -> bool { - note == OUTLINED_TEXT_NOTE || note == UNMATCHED_GLYPHS_NOTE || note.starts_with("text in ") && note.ends_with(UNNAMED_CHARACTERS_NOTE) + note == OUTLINED_TEXT_NOTE + || note == UNMATCHED_GLYPHS_NOTE + || note == RESHAPED_TEXT_NOTE + || note.starts_with("text in ") && note.ends_with(UNNAMED_CHARACTERS_NOTE) } /// Is `n` glyphs kept as outlines (a path the import names `<Text Outlines>`)? diff --git a/crates/pdf/src/import/reshaped.rs b/crates/pdf/src/import/reshaped.rs new file mode 100644 index 0000000000..77d70dfc1c --- /dev/null +++ b/crates/pdf/src/import/reshaped.rs @@ -0,0 +1,53 @@ +//! Type in a script whose letters reorder and join as they are set (#5235). A PDF draws such text +//! as glyphs already shaped: a vowel sign before the consonant it follows in reading order, a +//! conjunct as one glyph that the file may not name, a mark placed over its base. Read glyph by +//! glyph, the characters don't always make the text that was set, and type made from them draws +//! other glyphs in other places. A line of such type is therefore set again and compared with what +//! the file drew; one that would look different keeps the file's glyphs as outlines. + +use kurbo::{Rect, Shape}; +use vectorcraft_doc::TextObject; + +/// Does `text` have a character of a script whose letters reorder and join into conjuncts: the +/// Brahmic scripts of U+0900 to U+0DFF (Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, +/// Telugu, Kannada, Malayalam, Sinhala)? +pub(super) fn reshapes(text: &str) -> bool { + text.chars().any(|c| ('\u{0900}'..='\u{0DFF}').contains(&c)) +} + +/// Would type `t` draw its glyphs where the file drew them (`ink`: each glyph's ink box in +/// document space)? As many glyphs with ink, each within 3 % of the type's size of one of the +/// file's. +pub(super) fn draws_as(t: &TextObject, ink: &[Rect]) -> bool { + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let mut set: Vec<Rect> = + laid.glyphs.iter().filter(|g| !g.outline.elements().is_empty()).map(|g| (t.xf * g.outline.clone()).bounding_box()).collect(); + if set.len() != ink.len() { + return false; + } + let size = t.runs.iter().map(|r| r.style.size).fold(0.0, f64::max); + let tol = size * 0.03; + if !tol.is_finite() || set.iter().chain(ink).any(|r| !(r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite())) { + return false; + } + set.sort_by(|a, b| a.x0.total_cmp(&b.x0)); + let mut used = vec![false; set.len()]; + // Each of the file's glyphs takes one the type draws in its place: looked for among those + // whose left edge is as near. + ink.iter().all(|r| { + let from = set.partition_point(|s| s.x0 < r.x0 - tol); + let found = set + .iter() + .zip(used.iter_mut()) + .skip(from) + .take_while(|(s, _)| s.x0 <= r.x0 + tol) + .find(|(s, used)| !**used && (s.y0 - r.y0).abs() <= tol && (s.x1 - r.x1).abs() <= tol && (s.y1 - r.y1).abs() <= tol); + match found { + Some((_, used)) => { + *used = true; + true + } + None => false, + } + }) +} diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index 419952b017..b74cbf5dab 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -81,6 +81,10 @@ pub(super) struct Builder<'p> { pub(super) page: Rect, pub(super) glyphs: Option<GlyphRun>, pub(super) text: Option<TextLine>, + /// The glyphs of the open line of type (`text`) that show, each as the outline it would be + /// kept as: what the line becomes when its type wouldn't draw as the file does + /// ([`super::reshaped`]). + pub(super) text_glyphs: Vec<GlyphRun>, pub(super) text_as: TextAs, pub(super) actual: super::actual_text::ActualText, pub(super) images: HashMap<String, ImageBlob>, diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index b7163c0e58..200ec93673 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -269,6 +269,12 @@ impl TextLine { } } + /// Horizontal type whose characters are all read glyph by glyph from the font's mappings (none + /// from the file's own ActualText). + pub fn mapped(&self) -> bool { + self.upright.is_none() && !self.column && !self.authored.contains(&true) + } + /// The paint the last glyph is filled with. pub fn last_fill(&self) -> Option<&Paint> { self.runs.last()?.0.fill.as_ref() diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 234f4b8a8d..3f74dd9d28 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -80,8 +80,8 @@ pub use file_info::{FileInfo, file_info}; #[cfg(any(test, feature = "read-count"))] pub use import::imports_of; pub use import::{ - INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, - import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, + INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, RESHAPED_TEXT_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, + holds_outlines, import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, }; #[cfg(any(test, feature = "read-count"))] pub use pages::reads_of; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d8238d8038..4eeed94476 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -44,6 +44,7 @@ mod printadvanced; mod reader_compat; mod reader_streaming; mod realtext; +mod reshaped; mod settings; pub(super) mod stroke; mod strokeout; diff --git a/crates/pdf/src/tests/reshaped.rs b/crates/pdf/src/tests/reshaped.rs new file mode 100644 index 0000000000..060eca40b7 --- /dev/null +++ b/crates/pdf/src/tests/reshaped.rs @@ -0,0 +1,119 @@ +//! #5235: type in a script whose letters reorder as they are set (here Devanagari) opens as type +//! only when that type draws what the file drew; otherwise the file's glyphs stay, as outlines. +//! The file is written from scratch from the PDF specification (ISO 32000-1 §9.7, composite +//! fonts), its font program a test font built in code over Source Sans 3 +//! ([`vectorcraft_text::test_fonts::matra_font`]): Ka draws Source Sans 3's `a` and the vowel sign +//! I, set before its consonant, draws its `i`. + +use vectorcraft_doc::{Document, NodeKind}; +use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; +use vectorcraft_text::FontDb; +use vectorcraft_text::test_fonts::{MATRA_FAMILY, matra_font}; + +use crate::tests_import_fidelity::stream; +use crate::*; + +const KA: char = 'क'; +const SIGN_I: char = '\u{093F}'; + +/// The test font's glyph of `c` (Source Sans 3's glyph of the Latin letter that draws it) and its +/// advance in thousandths of an em. +fn glyph(c: char) -> (u32, f64) { + let face = FontDb::global().face(MATRA_FAMILY, "Regular").expect("the test font is added"); + let g = face.glyph_for(c); + (g, (face.advance(g) * 1000.0 / face.units_per_em()).round()) +} + +/// A 300 × 200 pt page showing the glyphs of `drawn`, in that order, in the test font at 40 pt +/// from (20, 100): a Type 0 font (Identity-H, two-byte glyph ids) with the font program embedded +/// and a ToUnicode map naming each glyph's character. `sign_width`: the advance the file gives +/// the vowel sign, in thousandths of an em (`None`: the font's). +fn page(drawn: &[char], sign_width: Option<f64>) -> Vec<u8> { + FontDb::global().add_font(matra_font().expect("the test font builds")); + let program = matra_font().unwrap(); + let hex: String = program.iter().map(|b| format!("{b:02X}")).collect::<String>() + ">"; + let n = first_extra(1); + let rows: Vec<String> = [KA, SIGN_I].iter().map(|&c| format!("<{:04X}> <{:04X}>", glyph(c).0, c as u32)).collect(); + let width = |c| sign_width.filter(|_| c == SIGN_I).unwrap_or(glyph(c).1); + let widths: Vec<String> = [KA, SIGN_I].iter().map(|&c| format!("{} [{}]", glyph(c).0, width(c))).collect(); + let objs = [ + stream(&format!("/Length1 {} /Filter /ASCIIHexDecode", program.len()), &hex), + format!( + "<< /Type /FontDescriptor /FontName /MatratestSans-Regular /Flags 4 /FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 1024 /Descent -400 /CapHeight 660 /StemV 80 /FontFile2 {n} 0 R >>" + ), + format!( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /MatratestSans-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {} 0 R /CIDToGIDMap /Identity /DW 1000 /W [{}] >>", + n + 1, + widths.join(" ") + ), + stream( + "", + &format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /Test-UCS def /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange {} beginbfchar\n{}\nendbfchar endcmap CMapName currentdict /CMap defineresource pop end end", + rows.len(), + rows.join("\n") + ), + ), + ]; + let refs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let resources = format!( + "/Font << /F1 << /Type /Font /Subtype /Type0 /BaseFont /MatratestSans-Regular /Encoding /Identity-H /DescendantFonts [{} 0 R] /ToUnicode {} 0 R >> >>", + n + 2, + n + 3 + ); + let shown: String = drawn.iter().map(|&c| format!("{:04X}", glyph(c).0)).collect(); + let content = format!("BT /F1 40 Tf 20 100 Td <{shown}> Tj ET"); + pdf_with(&[PdfPage { resources, ..PdfPage::new(300.0, 200.0, &content) }], &refs, None) +} + +fn open(bytes: &[u8]) -> ImportReport { + import_with_report(bytes, &ImportOptions { text_as: TextAs::Text, ..Default::default() }).unwrap() +} + +fn texts(d: &Document) -> Vec<String> { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + v.push(t.plain_text()); + } + }); + v +} + +fn outlines(d: &Document) -> usize { + let mut n = 0; + d.walk(|node| n += usize::from(is_glyph_outlines(node))); + n +} + +/// The file draws ककि as it is set: Ka, then the vowel sign before the Ka it follows in reading +/// order. Read glyph by glyph that is Ka, the sign, Ka, which reads किक: set as type, the sign +/// would move in front of the first Ka. The file's three glyphs stay as they are drawn. +#[test] +fn a_syllable_drawn_out_of_reading_order_keeps_the_files_glyphs() { + let report = open(&page(&[KA, SIGN_I, KA], None)); + assert_eq!(texts(&report.document), [""; 0], "no type that draws something else"); + assert_eq!(outlines(&report.document), 1, "the glyphs, as one path"); + assert!(report.warnings.iter().any(|w| w == RESHAPED_TEXT_NOTE), "{:?}", report.warnings); + assert!(report.warnings.iter().all(|w| !is_outlines_note(w) || w == RESHAPED_TEXT_NOTE), "{:?}", report.warnings); +} + +/// Letters the file draws in reading order make type that draws the same: they stay type. +#[test] +fn letters_drawn_in_reading_order_stay_type() { + let report = open(&page(&[KA, KA], None)); + assert_eq!(texts(&report.document), [format!("{KA}{KA}")]); + assert_eq!(outlines(&report.document), 0); + assert!(!report.warnings.iter().any(|w| is_outlines_note(w)), "{:?}", report.warnings); +} + +/// A mark the file gives no advance (as fonts do for the signs they set over a letter) belongs to +/// the line of its letter in a font written horizontally: it is not a glyph of vertical type, set +/// as an object of its own. Here the line it stays on then reads कि, whose sign type would set +/// before the Ka, so the line keeps the file's glyphs, the mark with them. +#[test] +fn a_mark_without_an_advance_stays_with_its_line() { + let report = open(&page(&[KA, SIGN_I], Some(0.0))); + assert_eq!(texts(&report.document), [""; 0], "the mark is no type of its own"); + assert_eq!(outlines(&report.document), 1, "both glyphs, as one path"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 89a048bd58..b908232cbf 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.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; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.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; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.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; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.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; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -340,6 +340,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | PDF import (#5235): a glyph with no advance in a font the file writes horizontally (a vowel sign or anusvara set over its letter) stays on its line instead of opening as vertical type of its own. A line of type in an Indic script (U+0900 to U+0DFF) is set again after it is read and compared with the glyphs the file drew: when it would draw other glyphs or put them elsewhere (a vowel sign the file draws before its consonant, a conjunct the file doesn't name), the line keeps the file's glyphs as outlines, with a note. Indic type in a font that isn't installed never draws as the file does, so it keeps the file's glyphs too; other type in such a font still shows in the fallback font (#4568). Tests: `crates/pdf/src/tests/reshaped.rs`. Scores unchanged. | | 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). | | 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). | | 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. | From 04fd1b127bc81bdc14f51f73553528f40f78c3c5 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 07:28:26 -0800 Subject: [PATCH 77/79] Brushes: Art Brush Options' Width can follow the pen's tilt, bearing and rotation The Width menu now offers Tilt, Bearing and Rotation beside Fixed and Pressure, each with the second width. Tilt goes from Width with the pen lying flat to the second width upright; Bearing and Rotation go from Width at -180 degrees to the second width at 180 degrees, as Scatter Brush Options' modes do. A stroke drawn without that input is half way between the two widths. brush.options takes width_mode tilt, bearing or rotation. --- crates/brush/src/lib.rs | 21 ++++--- crates/brush/src/tests.rs | 56 +++++++++++++++++++ crates/brush/src/warp.rs | 11 ++-- crates/engine/src/cmd/brushsym.rs | 2 +- .../ui-egui/src/dialogs/brush_art_options.rs | 24 ++++---- .../src/dialogs/brush_options/tests.rs | 23 ++++++++ docs/gaps.md | 2 +- docs/hardware-parity.md | 2 +- docs/target-app-parity.md | 4 +- 9 files changed, 113 insertions(+), 32 deletions(-) diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 448b2c2ba0..f25615c589 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -370,11 +370,14 @@ pub struct ArtBrush { pub scale: ArtScale, /// Width in percent. pub width: f64, - /// How the width varies along a stroke: `None` (Fixed) always `width`; Pressure goes from - /// `width` at the lightest pen pressure to [`Self::width_max`] at the heaviest. + /// How the width varies along a stroke: `None` (Fixed) always `width`; a mode that follows the + /// pen goes from `width` to [`Self::width_max`] over the ranges [`Variation`] gives: Pressure + /// from the lightest pressure to the heaviest, Tilt from the pen lying flat to upright, Bearing + /// and Rotation from −180° to 180° (half way at 0°). #[serde(skip_serializing_if = "Option::is_none")] pub width_mode: Option<Variation>, - /// The width at the heaviest pen pressure (%), for Pressure (`width` without one). + /// The second width (%) of a mode that follows the pen: at the heaviest pressure, upright, or + /// at 180° (`width` without one). #[serde(skip_serializing_if = "Option::is_none")] pub width_max: Option<f64>, pub flip_along: bool, @@ -388,11 +391,11 @@ impl ArtBrush { /// The range of the width (%). pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0); - /// The widths (%) at the lightest and the heaviest pen pressure: `width` for both unless the - /// width follows the pressure. + /// The widths (%) where the pen reads 0 and 1 for the width's mode (the lightest and the + /// heaviest pressure, for Pressure): `width` for both unless the width follows the pen. pub fn width_range(&self) -> (f64, f64) { match self.width_mode { - Some(Variation::Pressure) => (self.width, self.width_max.unwrap_or(self.width)), + Some(m) if m.follows_pen() => (self.width, self.width_max.unwrap_or(self.width)), _ => (self.width, self.width), } } @@ -713,8 +716,8 @@ impl Brush { } BrushKind::Art(a) => { a.width = within(a.width, ArtBrush::WIDTH_RANGE, 100.0); - // The width follows the pen's pressure or stays fixed. - if a.width_mode != Some(Variation::Pressure) { + // The width follows the pen (its pressure, tilt, bearing or rotation) or stays fixed. + if !a.width_mode.is_some_and(Variation::follows_pen) { a.width_mode = None; } a.width_max = a.width_max.map(|w| within(w, ArtBrush::WIDTH_RANGE, a.width)); @@ -806,7 +809,7 @@ pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &Strok let mut out = match &brush.kind { BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name), BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name), - BrushKind::Art(a) => warp::art(a, bp, w, stroke.pressure.as_ref()), + BrushKind::Art(a) => warp::art(a, bp, w, stroke), BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke, &brush.name), BrushKind::Pattern(p) => warp::pattern(p, bp, w), }; diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 11c83d97bc..affd2fa6b7 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -1371,6 +1371,62 @@ fn art_brush_width_follows_the_pressure() { assert!(plain.get("width_mode").is_none() && plain.get("width_max").is_none(), "{plain}"); } +/// Art Brush Options: Width on Tilt goes from Width with the pen lying flat to the second width +/// upright, and on Bearing or Rotation from Width at −180° to the second width at 180°, along the +/// stroke as the pen recorded it; each mode reads only its own input. +#[test] +fn art_brush_width_follows_tilt_bearing_and_rotation() { + let brush = |mode: Variation| { + let mut b = art_brush(rect_art(10.0, 4.0), ArtScale::Stretch); + if let BrushKind::Art(a) = &mut b.kind { + (a.width, a.width_mode, a.width_max) = (50.0, Some(mode), Some(300.0)); + } + b + }; + let bp = line(0.0, 0.0, 100.0, 0.0); + let draw = |b: &Brush, st: &StrokeLayer| stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st); + // Tilt, upright at the start (300 %: 6 pt either side of 4 pt art) to flat at the end (50 %: 1 pt). + let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) }; + let out = draw(&brush(Variation::Tilt), &upright_to_flat); + assert!(near(half_width(&out, 0.0, 1.0), 6.0, 0.1), "{}", half_width(&out, 0.0, 1.0)); + assert!(near(half_width(&out, 99.0, 100.0), 1.0, 0.1), "{}", half_width(&out, 99.0, 100.0)); + assert!(near(half_width(&out, 49.0, 51.0), 3.5, 0.15), "{}", half_width(&out, 49.0, 51.0)); + // Bearing and Rotation: half way at 0°, a quarter of the way at −90°, three quarters at 90°. + for (mode, angle, half) in [ + (Variation::Bearing, 0.0, 3.5), + (Variation::Bearing, -90.0, 2.25), + (Variation::Bearing, 90.0, 4.75), + (Variation::Rotation, -90.0, 2.25), + (Variation::Rotation, 90.0, 4.75), + ] { + let st = if mode == Variation::Bearing { posed(Some(0.5), Some(angle), None) } else { posed(None, None, Some(angle)) }; + let h = bounds(&draw(&brush(mode), &st)).height(); + assert!(near(h, 2.0 * half, 1e-6), "{mode:?} {angle}: {h}"); + } + // Each mode reads its own input: without it (the pressure, or another of the pen's values + // recorded instead), the art is half way between the two widths. + let others = [ + (Variation::Tilt, posed(None, Some(90.0), Some(90.0))), + (Variation::Tilt, pressed(vec![(0.0, 0.0), (1.0, 1.0)])), + (Variation::Bearing, posed(Some(0.0), None, Some(90.0))), + (Variation::Rotation, posed(Some(0.0), Some(90.0), None)), + (Variation::Pressure, posed(Some(0.0), Some(90.0), Some(90.0))), + ]; + for (mode, st) in others { + let h = bounds(&draw(&brush(mode), &st)).height(); + assert!(near(h, 7.0, 1e-6), "{mode:?}: {h}"); + } + // The pen's modes are kept when a definition is read, and Random is read as Fixed. + let lib = parse_library(&serde_json::json!([ + {"name": "t", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "tilt", "width_max": 300.0}, + {"name": "b", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "bearing", "width_max": 300.0}, + {"name": "r", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "rotation", "width_max": 300.0}, + {"name": "x", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "random", "width_max": 300.0}, + ])); + let modes: Vec<_> = lib.iter().map(|b| if let BrushKind::Art(a) = &b.kind { a.width_mode } else { None }).collect(); + assert_eq!(modes, [Some(Variation::Tilt), Some(Variation::Bearing), Some(Variation::Rotation), None]); +} + /// Definitions from files and commands are kept within their ranges. #[test] fn sanitize_keeps_values_within_their_ranges() { diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index b34a1cf31e..c72e1c33f8 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use kurbo::ParamCurveNearest; -use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile, StrokeLayer}; +use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath, CubicBez, ParamCurve, PathData, PathEl, Point, Rect, SubPath, Vec2}; use crate::track::{Rng, Track, normal, seed_of, tracks}; @@ -503,9 +503,10 @@ fn art_on_run( bend_widened(t, art, b, along, cross, widen) } -/// An art brush's art along `bp`. With a width that follows the pen's pressure, the art is as -/// wide as the pressure recorded at each point of the stroke gives (half way without a recording). -pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>) -> Vec<Node> { +/// An art brush's art along `bp`. With a width that follows the pen (its pressure, tilt, bearing +/// or rotation), the art is as wide as what `stroke` recorded at each point gives (half way +/// without a recording). +pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, stroke: &StrokeLayer) -> Vec<Node> { let Some((art, b)) = oriented(&a.art, a.direction, a.flip_along, a.flip_across) else { return vec![] }; let (light, heavy) = a.width_range(); let width = light.max(heavy); @@ -522,7 +523,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre } // A run past the start of a closed track wraps round to it. let at = if s > l { s - l } else { s }; - let p = pressure.map_or(PressureProfile::MID, |p| p.at(at / l)).clamp(0.0, 1.0); + let p = a.width_mode.and_then(|m| PenAt::of(stroke, at / l).control(m)).unwrap_or(0.5); (light + (heavy - light) * p) / width }; let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, l)] }; diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index fb8af2b11e..76b4e1a715 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -162,7 +162,7 @@ pub fn specs() -> Vec<CommandSpec> { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure|tilt|bearing|rotation, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest; tilt: width with the pen lying flat to width_max upright; bearing and rotation: width at −180° to width_max at 180°; half way on a stroke drawn without that input), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), diff --git a/crates/ui-egui/src/dialogs/brush_art_options.rs b/crates/ui-egui/src/dialogs/brush_art_options.rs index c511bb2b58..8bc0e66a2e 100644 --- a/crates/ui-egui/src/dialogs/brush_art_options.rs +++ b/crates/ui-egui/src/dialogs/brush_art_options.rs @@ -10,8 +10,8 @@ //! Scatter: per value (`size`, `spacing`, `scatter` %, `rotation` °) `…Min`, `…Max` and `…Mode` //! (`fixed` | `random` | `pressure` | `tilt` | `bearing` | `rotation`), `rotationRelativeTo` (`page` | //! `path`). -//! Art: `width` (%), `widthMode` (`fixed` | `pressure`), `widthMax` (the width at the heaviest pen -//! pressure, %), `scaleMode` (`proportional` | `stretch` | `betweenGuides`), `guideStart` and +//! Art: `width` (%), `widthMode` (`fixed` | `pressure` | `tilt` | `bearing` | `rotation`), +//! `widthMax` (the width at the heaviest pen pressure, upright or at 180°, %), `scaleMode` (`proportional` | `stretch` | `betweenGuides`), `guideStart` and //! `guideEnd` (% of the art's length), `direction` (`leftToRight` …), `flipAlong`, `flipAcross`, //! `overlap` (bend on each run between sharp corners). //! Pattern: `scale`, `spacing` (%), `fit` (`stretch` | `addSpace` | `approximate`), `flipAlong`, @@ -254,12 +254,9 @@ fn scatter_mode(d: &Dialog, key: &str) -> Variation { Variation::parse(&d.str(&mode_key(key))).unwrap_or_default() } -/// How an art brush's width varies: Fixed or Pressure. +/// How an art brush's width varies: Fixed, or with the pen's pressure, tilt, bearing or rotation. fn art_width_mode(d: &Dialog) -> Variation { - match Variation::parse(&d.str("widthMode")) { - Some(Variation::Pressure) => Variation::Pressure, - _ => Variation::Fixed, - } + Variation::parse(&d.str("widthMode")).filter(|m| m.follows_pen()).unwrap_or(Variation::Fixed) } /// `brush.options` params from the fields. @@ -282,11 +279,12 @@ pub(super) fn params(d: &Dialog) -> Value { }, _ => ArtScale::Stretch, }; - let pressure = art_width_mode(d) == Variation::Pressure; + let mode = art_width_mode(d); + let pen = mode.follows_pen(); json!({ "width": clamped(d, "width", ArtBrush::WIDTH_RANGE), - "width_mode": if pressure { json!(Variation::Pressure.id()) } else { Value::Null }, - "width_max": if pressure { json!(clamped(d, "widthMax", ArtBrush::WIDTH_RANGE)) } else { Value::Null }, + "width_mode": if pen { json!(mode.id()) } else { Value::Null }, + "width_max": if pen { json!(clamped(d, "widthMax", ArtBrush::WIDTH_RANGE)) } else { Value::Null }, "scale": serde_json::to_value(scale).unwrap_or_default(), "direction": id_of(&parse_field::<Direction>(d, "direction")), "flip_along": d.bool("flipAlong"), @@ -462,14 +460,14 @@ fn scatter_body(ui: &mut egui::Ui, d: &mut Dialog) { } fn art_body(ui: &mut egui::Ui, d: &mut Dialog) { - // Width, then the width at the heaviest pen pressure (only with Pressure) and how it varies. + // Width, then the second width (only when the width follows the pen) and how it varies. let rail = Tokens::get(ui.ctx()).input_border; let range = ArtBrush::WIDTH_RANGE; widgets::label_row(ui, tl!("Width:"), LABEL_W, |ui| { super::form::slider_field_named(ui, d, "width", tl!("Width:"), range.0..=range.1, "%", &|_| rail); let mode = art_width_mode(d); - number(ui, d, "widthMax", range, "%", mode == Variation::Pressure); - let modes = [Variation::Fixed, Variation::Pressure]; + number(ui, d, "widthMax", range, "%", mode.follows_pen()); + let modes = [Variation::Fixed, Variation::Pressure, Variation::Tilt, Variation::Bearing, Variation::Rotation]; if let Some(m) = widgets::dropdown(ui, "brush-art-width-mode", mode.label(), &modes.map(Variation::label), 104.0).and_then(|i| modes.get(i)) { d.fields.insert("widthMode".into(), json!(m.id())); } diff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs index 3ae028bb05..07577c127c 100644 --- a/crates/ui-egui/src/dialogs/brush_options/tests.rs +++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs @@ -275,6 +275,29 @@ fn art_options_width_follows_the_pen_pressure() { assert!((drawn_height(&app, id) - before).abs() < 1e-6); } +/// Art Brush Options' Width menu also offers Tilt, Bearing and Rotation, each with the second +/// width, and shows the one set when it opens again. +#[test] +fn art_options_width_follows_tilt_bearing_and_rotation() { + let mut app = app(); + let (id, before) = brushed(&mut app, "Tapered Stroke"); + for (mode, label) in [("tilt", "Tilt"), ("bearing", "Bearing"), ("rotation", "Rotation")] { + app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap(); + set(&mut app, json!({"widthMode": mode, "widthMax": 300})); + assert!(shown(&mut app).contains(label), "{mode}"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Tapered Stroke"})).unwrap(); + assert_eq!((&def["width_mode"], &def["width_max"]), (&json!(mode), &json!(300.0)), "{mode}"); + // A stroke drawn without the pen's input is half way: 200 %. + let after = drawn_height(&app, id); + assert!((after - 2.0 * before).abs() < 0.01 * before, "{mode}: {before} then {after}"); + app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("widthMode"), d.f64("widthMax", 0.0)), (mode.into(), 300.0), "{mode}"); + super::super::cancel(&mut app); + } +} + /// A context with the app's fonts and theme, for [`frame`]. fn ctx() -> egui::Context { let ctx = egui::Context::default(); diff --git a/docs/gaps.md b/docs/gaps.md index 507b0487df..b0c39505b1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -592,7 +592,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi On Windows with Windows Ink, the pen's tilt and barrel rotation where the pen reports them (#372; not yet tried with a pen). - **Missing:** pen pressure on native Wayland (#491, #764) — winit 0.30 reports none there; tilt, bearing and - barrel rotation on native Wayland and the web, and for Art, Pattern and Bristle brushes (6D Art Pen, #372); + barrel rotation on native Wayland and the web, and for Pattern and Bristle brushes (6D Art Pen, #372); the eraser end on native Wayland. - **Estimate:** 4–10 h, partly waiting on winit 0.31; needs pen hardware to verify. diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index ce9f55cf50..31a218eb6c 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -22,7 +22,7 @@ It reads the pen's tilt and barrel rotation on macOS and on Linux under X11 and | Hybrid-graphics GPU choice | automatic | power-saving GPU by default, Preferences › Graphics Processor | same; DX12 then OpenGL; no Vulkan unless asked (#545, #806) | the desktop's GPU | browser | done | | Restart on another GPU when the first fails to present | — | yes (#502, #964) | yes | yes | restart on lost device (#369) | done | | Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | yes, read from AppKit's tablet events before the window gets them (winit reports none), each move of a stroke with its own pressure, mouse event coalescing off (#852; not yet tried on a Mac) | yes (pointer force via `WM_POINTER`) | under X11 and XWayland (Preferences › Performance › Linux Display Server, #1151, #2028; Automatic uses XWayland when a pen is attached on a Wayland session, #4967), read from XInput2 raw events (#764); **no** on native Wayland, where winit reports none and the pen may not reach the window (#491) | browser pointer events, with coalesced events; Apple Pencil on iPad (hover, palm rejection: fingers navigate after a pen contact until reload) | partial: 2–4 h for Wayland (winit 0.31), plus a check on a Mac | -| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | read from AppKit's tablet events (tilt, and the barrel rotation of a pen that reports one); Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac) | with Windows Ink, read from the pen's `POINTER_PEN_INFO` (tilt and barrel rotation where its pen mask says the pen reports them); Calligraphic and Scatter brushes vary with them (#372; not yet tried with a pen) | under X11 and XWayland, read from XInput2 raw events; Calligraphic and Scatter brushes vary with them (#764); **no** on native Wayland | no | partial: native Wayland and the web read none, and Art, Pattern and Bristle brushes do not use them; 3–6 h (#372) | +| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | read from AppKit's tablet events (tilt, and the barrel rotation of a pen that reports one); Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac) | with Windows Ink, read from the pen's `POINTER_PEN_INFO` (tilt and barrel rotation where its pen mask says the pen reports them); Calligraphic and Scatter brushes vary with them (#372; not yet tried with a pen) | under X11 and XWayland, read from XInput2 raw events; Calligraphic and Scatter brushes vary with them (#764); **no** on native Wayland | no | partial: native Wayland and the web read none, and Pattern and Bristle brushes do not use them (an Art brush's Width follows them); 3–6 h (#372) | | Pen eraser end | yes | the eraser end (AppKit's tablet proximity events) selects the Eraser tool and the tip brings back the tool used before, as on Linux (#852; not yet tried on a Mac) | with Windows Ink, an inverted pen or its eraser button (`PEN_FLAG_INVERTED`, `PEN_FLAG_ERASER` in the pen's pointer messages) selects the Eraser tool and the tip brings back the tool used before, as on Linux (not yet tried with a pen) | under X11 and XWayland, the eraser end selects the Eraser tool and the tip brings back the tool used before (#764); **no** on native Wayland | a press of the eraser end (Pointer Events eraser button, `button` 5) selects the Eraser tool, and that press erases; a press of the tip brings back the tool used before; Pointer Events report no end while the pen hovers | partial | | Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch, scroll and rotate | pinch, pan (a second finger cancels the first finger's tool press, #4198); two-finger tap undo, three-finger redo (#585) | same where winit reports touch | eframe until the first pen contact, then Pointer Events (pen and fingers told apart); long press opens the context menu | partial: touch workspace and touch-screen rotation missing, 3–5 h | | Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d98bb46575..bab1d4433e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1352,12 +1352,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. The canvas, the gradient chips and the SVG, PDF, EPS and EMF writers mix the points in that model too. PDF and EPS write a gradient whose points are all CMYK, or all Gray in a CMYK file, as a CMYK image, which PDF's Output settings convert as they convert any CMYK image. Points in different models mix in display RGB (on the canvas in a CMYK document that shows transparency, as the inks of each point), and a CMYK document stores the result of Expand as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty-one more arrowheads (among them Line Arrow: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, cut square, #5421), and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, with the one-sided three-point PCHIP slope at the profile's ends and flat either side of a step), sampled adaptively along the path (two samples per profile segment, refined where the width bends, and leaning on dense path flattening) so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; the sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt at the default 0.01 pt) rather than one cubic per flatten step, so exports stay close to a uniform stroke's path data; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. - - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across, Overlap) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Art Brush Options › Overlap places art on each run between sharp corners so folds do not overlap (`ArtBrush.overlap`; `art_brush_overlap_splits_at_corners`). Still to come: Stylus Wheel, pen pressure on macOS where the windowing layer reports none, and tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web. + - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across, Overlap) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Art Brush Options › Overlap places art on each run between sharp corners so folds do not overlap (`ArtBrush.overlap`; `art_brush_overlap_splits_at_corners`). Still to come: Stylus Wheel, pen pressure on macOS where the windowing layer reports none, and tilt, bearing and rotation for Pattern and Bristle brushes and on native Wayland and the web. Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules. Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>) and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush). - **Pen tilt, bearing and rotation (part of #764):** Calligraphic and Scatter Brush Options offer Tilt, Bearing and Rotation beside Fixed, Random and Pressure. The Paintbrush records along each stroke how far the pen leans, which way, and how far its barrel is turned (kept with the path, saved, and joined when a stroke continues a path). A Calligraphic value on Tilt goes from value − variation with the pen lying flat to value + variation upright; on Bearing or Rotation it adds variation × the angle over 180°, so Angle on Bearing with a Variation of 180° turns the nib with the way the pen leans. A Scatter value goes from its first value to its second over the same ranges. A stroke without that input draws the brush's own value. On Linux under X11 and XWayland the app reads the tilt and an Art Pen's barrel rotation from XInput2, on macOS it reads them from AppKit's tablet events, and `path.freehand` points and `pointer_gesture` events carry them on every platform. The [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page defines the three options and does not say which way each one runs; that mapping is VectorCraft's own rule. Evidence: `tests::tilt_maps_to_roundness_along_the_stroke`, `tests::bearing_and_rotation_turn_the_nib`, `tests::scatter_values_follow_tilt_and_bearing` (vectorcraft-brush), `tests_brushsym::paintbrush_records_the_pens_tilt_on_the_stroke` (vectorcraft-engine), `tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` and `mac_tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` (vectorcraft). Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value. - Art Brush Options' Width has a Fixed or Pressure menu, from the pen tablet options of the Width pop-up menu on the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy). With Pressure, the art is Width wide at the lightest pen pressure and the second width at the heaviest, following the pressure the Paintbrush recorded along the stroke, and half way between them on a stroke without recorded pressure (`brush.options` `width_mode` and `width_max`); a stroke inside the art takes the width at the middle of its object. The page does not say how the pressure maps to the width; taking the Scatter brush's minimum and maximum rule and the stroke rule are VectorCraft's own. Stylus Wheel, Tilt, Bearing and Rotation are not built. + Art Brush Options' Width has a Fixed or Pressure menu, from the pen tablet options of the Width pop-up menu on the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy). With Pressure, the art is Width wide at the lightest pen pressure and the second width at the heaviest, following the pressure the Paintbrush recorded along the stroke, and half way between them on a stroke without recorded pressure (`brush.options` `width_mode` and `width_max`); a stroke inside the art takes the width at the middle of its object. The page does not say how the pressure maps to the width; taking the Scatter brush's minimum and maximum rule and the stroke rule are VectorCraft's own. The menu also offers Tilt, Bearing and Rotation: the art goes from Width with the pen lying flat to the second width upright, and from Width at −180° to the second width at 180° of the pen's bearing or barrel rotation, as Scatter Brush Options' modes do, and half way on a stroke without that input; the page does not say which way each runs, so that mapping is VectorCraft's own. Evidence: `tests::art_brush_width_follows_tilt_bearing_and_rotation` (vectorcraft-brush). Stylus Wheel is not built. Scatter, Art and Pattern Brush Options have the Tips button for Colorization that the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes, beside Method. It opens Colorization Tips, which shows each method's name and what it does beside a sample of the brush, as OK would make it, painted with that method on a blue stroke; Hue Shift's sample uses the dialog's Key Color. `ui.dialog.set {field: "tips", value: true}` opens it. Screen readers name the button, the popover and each sample. Hue Shift set by `brush.new` or `brush.options` without a key color takes the color the art's fills and strokes use most, as Brush Options already offers it, following the Brushes page, where the key color defaults to the color that stands out most in the art (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>). The page does not state how prominence is measured. Counting the fills and strokes that use each solid color, with the first one found winning a tie and black for art with no solid color, is VectorCraft's own rule. A `brush.options` edit that gives only the method keeps the brush's key color, and one that gives only an art brush's start guide keeps its end guide. Evidence: `tests_brushsym::hue_shift_without_a_key_takes_the_most_used_art_color`, `tests_brushsym::art_brush_options_keep_the_scale_fields_an_edit_leaves_out`. Bristle Brush Options and `brush.new` take a bristle brush name of at most 31 characters, as the Brushes page states for the Bristle Brush Options. The Name field stops taking characters at 31, and `brush.new` and `brush.options` refuse a longer new name with an error that names the limit. The page does not say how the limit counts characters or what happens to longer names already saved; counting characters and the number added to a taken name, keeping longer saved names, and leaving `brush.duplicate` unlimited are VectorCraft's own rules. A bristle brush that a document or a saved file already holds under a longer name keeps it, and the other brush types take a name of any length. Test: `bristle_brush_names_have_at_most_31_characters` in `tests/brushsym.rs`. Public reference: [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) (archived copy). From d3b18465f94574e674e48202c6cade9674a1853d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 07:45:32 -0800 Subject: [PATCH 78/79] New Document: file.new and artboard.new take the same largest artboard side file.new refused a side over 163,830 pt, while artboard.new took larger artboards. Both now take each side more than 0 and at most 3,999,856 pt: the 4,000,000 pt the canvas reaches from the origin, less a 72 pt bleed on both sides. A side outside that range is refused with an error that names the limit in points and, when the document's units are not points, in those units, rounded down so the value shown is accepted. artboard.new now refuses a supplied width or height of 0 or less instead of making a 1 pt side, and refuses a size taken from a larger artboard. Saved and Recent presets are read with sides up to the new limit. Follows #6643. --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/layer.rs | 9 +++++- crates/engine/src/cmd/newdoc.rs | 29 ++++++++++++++++--- crates/engine/src/tests/newdoc.rs | 47 +++++++++++++++++++++++++++++-- crates/ui-egui/src/i18n/hi.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- docs/mcp.md | 6 +++- 7 files changed, 86 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 4f3dc3403a..b7affffa81 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> { "New…", ["File"], None, - "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new ), diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 593d325460..43d9a8e05f 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> { "New Artboard", ["Window", "Artboards"], None, - "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", + "{x?, y?, width?, height? (each more than 0 and at most 3,999,856 pt, as file.new takes them), name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)", has_doc, artboard_new ), @@ -926,6 +926,9 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> { let guide_label = artboard_guide_label(p, "artboard.new")?.flatten(); check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?; check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?; + if ["width", "height"].iter().filter_map(|k| p.get(*k).and_then(Value::as_f64)).any(|side| !super::newdoc::side_in_range(side)) { + return Err(super::newdoc::side_error("artboard.new", d0.units)); + } let r = match artboard_side(p, "artboard.new")? { Some(side) => { if ["x", "y", "width", "height"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { @@ -941,6 +944,10 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> { None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")), None => rect_from(p, def), }; + // A size taken from another artboard (`side`, or width or height left out) keeps to the limit too. + if !super::newdoc::side_in_range(r.width()) || !super::newdoc::side_in_range(r.height()) { + return Err(super::newdoc::side_error("artboard.new", d0.units)); + } let given = artboard_str(p, "name", "artboard.new")?.map(str::trim).filter(|n| !n.is_empty()); let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false)); let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false); diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index bd15d748ca..58c7974460 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -241,8 +241,8 @@ impl DocSettings { if let Some(h) = length("height", self.units)? { self.height = h; } - if !(self.width > 0.0 && self.height > 0.0) || self.width > MAX_SIDE || self.height > MAX_SIDE { - return Err(bad(cmd, "width/height must be positive and within the canvas")); + if !side_in_range(self.width) || !side_in_range(self.height) { + return Err(side_error(cmd, self.units)); } match p.get("orientation").and_then(Value::as_str) { None => {} @@ -359,8 +359,29 @@ impl DocSettings { } } -/// Largest artboard side `file.new` accepts, points. -const MAX_SIDE: f64 = 16383.0 * 10.0; +/// Largest artboard side `file.new` and `artboard.new` accept, points: the span of the canvas +/// ([`crate::MAX_COORD`] from the origin) less the largest bleed on both sides, 3,999,856 pt. +pub(crate) const MAX_SIDE: f64 = crate::MAX_COORD - 2.0 * vectorcraft_doc::setup::MAX_BLEED; + +/// Is `side` an artboard side `file.new` and `artboard.new` accept: more than 0 and at most [`MAX_SIDE`]? +pub(crate) fn side_in_range(side: f64) -> bool { + side > 0.0 && side <= MAX_SIDE +} + +/// The error of an artboard side outside [`side_in_range`]: the limit in points, and in `units` as +/// well when they aren't points, rounded down to the decimals [`Unit::number`] shows so the value +/// shown is one the commands take. +pub(crate) fn side_error(cmd: &str, units: Unit) -> crate::EngineError { + let limit = match units { + Unit::Points => format!("{MAX_SIDE} pt"), + u => { + let step = if matches!(u, Unit::Pixels | Unit::Picas | Unit::Q | Unit::Ha) { 1e3 } else { 1e4 }; + let shown = (u.from_pt(MAX_SIDE) * step).floor() / step; + format!("{MAX_SIDE} pt ({} {})", u.number(u.to_pt(shown)), u.suffix()) + } + }; + bad(cmd, format!("width and height must be more than 0 and at most {limit}")) +} /// Largest artboard spacing, points. const MAX_SPACING: f64 = 1.0e5; diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 4bc58e5f6b..0b573a96f3 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -233,7 +233,7 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() { saved, [ (1.0, 1.0, 1.0, 0.0, vec![0.0, 0.0, 0.0, 9.0]), - (163830.0, 163830.0, 1.0, 1.0e5, vec![72.0, 72.0, 72.0, 0.0]), + (3_999_856.0, 200000.0, 1.0, 1.0e5, vec![72.0, 72.0, 72.0, 0.0]), (10.0, 10.0, 1000.0, 0.0, vec![0.0; 4]), ] ); @@ -242,7 +242,7 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() { s.execute("file.new", &json!({"preset": "Low"})).unwrap(); assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 1.0, 1.0)], [0.0, 0.0, 0.0, 9.0])); s.execute("file.new", &json!({"preset": "High"})).unwrap(); - assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 163830.0, 163830.0)], [72.0, 72.0, 72.0, 0.0])); + assert_eq!((boards(&s), s.doc().unwrap().doc.setup.bleed), (vec![Rect::new(0.0, 0.0, 3_999_856.0, 200000.0)], [72.0, 72.0, 72.0, 0.0])); s.execute("file.new", &json!({"preset": "Many"})).unwrap(); assert_eq!(boards(&s).len(), 1000); let custom = presets(&mut s, "Recent").into_iter().find(|p| p["name"] == "Custom").unwrap(); @@ -250,6 +250,49 @@ fn a_saved_preset_with_values_out_of_range_reads_in_range() { assert_eq!(boards(&s).iter().map(|r| r.x0).collect::<Vec<_>>(), [0.0, 100.0, 200.0], "side by side, not overlapping"); } +/// `file.new` and `artboard.new` take the same largest artboard side (the canvas less the largest +/// bleed on both sides) and refuse a side just past it, or of 0 or less, with an error that names +/// the limit in points and, when the document's units aren't points, in those units too. +#[test] +fn file_new_and_artboard_new_take_the_same_largest_side() { + let max = 3_999_856.0; + let in_meters = "at most 3999856 pt (1411.0603 m)"; + let s = new_doc(json!({"width": max, "height": 170078})); + assert_eq!(boards(&s), [Rect::new(0.0, 0.0, max, 170078.0)]); + let mut s = Session::new(); + let err = s.execute("file.new", &json!({"width": max + 1.0, "height": 100})).unwrap_err().to_string(); + assert!(err.contains("width and height must be more than 0 and at most 3999856 pt") && !err.contains('('), "{err}"); + let err = s.execute("file.new", &json!({"width": 100, "height": "1412 m", "units": "Meters"})).unwrap_err().to_string(); + assert!(err.contains(in_meters), "{err}"); + // The value in other units is rounded down, so it is one file.new takes. + let err = s.execute("file.new", &json!({"width": 100, "height": "56000 in", "units": "Inches"})).unwrap_err().to_string(); + assert!(err.contains("at most 3999856 pt (55553.5555 in)"), "{err}"); + assert!(s.execute("file.new", &json!({"width": 0, "height": 100})).is_err()); + assert!(s.doc().is_err(), "no document was made"); + s.execute("file.new", &json!({"width": "55553.5555 in", "height": 100, "units": "Inches"})).unwrap(); + + let mut s = new_doc(json!({"units": "Meters"})); + s.execute("artboard.new", &json!({"x": -2_000_000, "y": 0, "width": max, "height": 100})).unwrap(); + assert_eq!(boards(&s)[1], Rect::new(-2_000_000.0, 0.0, max - 2_000_000.0, 100.0)); + for p in [ + json!({"x": -2_000_000, "y": 0, "width": max + 1.0, "height": 100}), + json!({"x": 0, "y": 2000, "width": 100, "height": max + 1.0}), + json!({"width": 0}), + json!({"height": -1}), + ] { + let err = s.execute("artboard.new", &p).unwrap_err().to_string(); + assert!(err.contains(in_meters), "{p}: {err}"); + } + assert_eq!(boards(&s).len(), 2, "the refused artboards were not added"); + // A size taken from a wider artboard is refused too. + s.execute("artboard.setProps", &json!({"index": 1, "x": -2_000_000, "y": 0, "width": max + 1.0, "height": 100})).unwrap(); + for p in [json!({}), json!({"x": 0, "y": 5000, "height": 100}), json!({"side": "bottom", "beside": 1})] { + let err = s.execute("artboard.new", &p).unwrap_err().to_string(); + assert!(err.contains(in_meters), "{p}: {err}"); + } + assert_eq!(boards(&s).len(), 2); +} + #[test] fn saved_presets_live_in_the_preferences() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 8f66a1b354..098d1917d6 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8116,7 +8116,7 @@ antiAlias Sharp शार्प @msg width must be positive width: चौड़ाई सकारात्मक होना चाहिए @msg width out of range सीमा से बाहर चौड़ाई @msg width/height must be > 0 चौड़ाई / ऊंचाई होना चाहिए > 0 -@msg width/height must be positive and within the canvas चौड़ाई / ऊंचाई सकारात्मक होना चाहिए और कैनवास के भीतर होना चाहिए +@msg width and height must be more than 0 and at most {limit} चौड़ाई और ऊंचाई 0 से अधिक और अधिकतम {limit} होनी चाहिए @msg workspace name can't be empty कार्यक्षेत्र का नाम खाली नहीं हो सकता है @msg x and y must be finite x और y को परिमित होना चाहिए @msg x and y must be finite numbers x और y होना चाहिए परिमित संख्या diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 48d1cced76..cfa885c8e4 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8366,7 +8366,7 @@ digits Hindi Hindi @msg width must be positive width måste vara positiv @msg width out of range bredden ligger utanför tillåtet omfång @msg width/height must be > 0 bredd/höjd måste vara > 0 -@msg width/height must be positive and within the canvas bredd/höjd måste vara positiva och inom arbetsytan +@msg width and height must be more than 0 and at most {limit} bredd och höjd måste vara större än 0 och högst {limit} @msg x and y must be finite numbers x och y måste vara ändliga tal @msg x and y must be given together x och y måste anges tillsammans @msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone behöver ett ankare: docka bredvid en panel med anchor eller i en ny dockkolumn med side (Start eller End) diff --git a/docs/mcp.md b/docs/mcp.md index fa22c66414..b5904d0367 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5402,7 +5402,11 @@ value outside that range is read as the nearer end), `previewMode` and `alignNew `file.newPresets {category?}` lists the categories and presets (Recent: the last sizes used; Saved: the user's presets, kept in the preferences by `file.newPresets.save` and removed by `file.newPresets.delete`). A listed preset can be passed straight back to `file.new`. Print presets and sizes without `units` start in `unitsGeneral`; screen presets -(mobile, web, video, social) in Pixels. A saved or Recent preset whose size (up to 163,830 pt), `artboards` (1 to +(mobile, web, video, social) in Pixels. `file.new` and `artboard.new` take the same artboard sizes: each side more than 0 +and at most 3,999,856 pt (the 4,000,000 pt the canvas reaches from the origin, less a 72 pt bleed on both sides). A side outside that range is +refused with `width and height must be more than 0 and at most 3999856 pt`, followed by the limit in the document's +units in parentheses when they are not points, such as `(1411.0603 m)`, and no document or artboard is made. +A saved or Recent preset whose size (up to 3,999,856 pt), `artboards` (1 to 1,000), spacing (0 to 100,000 pt) or bleed (0 to 72 pt a side) lies outside those ranges is read with the nearer end of each range, and a size of 0 or less is read as 1 pt. The desktop app's `file.newDialog` (Cmd/Ctrl+N) opens the New Document dialog on the first Recent entry, or on the From 71851c251f175ee051d4940eb345d66d004f399d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 07:51:40 -0800 Subject: [PATCH 79/79] Render tests: the kept-tile decode-failure test passes while other tests add fonts pattern::tests::kept_tiles_count_the_images_they_could_not_decode draws a pattern whose art is an image three times and expects one tile to be rasterized. A tile of art with an image is kept only while FontDb::global().generation() is the one it was drawn at (Deps::current in pattern.rs). The render tests that hold adding_fonts() add fonts, which increases it (tests/fontchange.rs, tests/typeedges.rs, tests/alternate.rs, tests/colrglyphs.rs and tests/bitmapglyphs.rs). When one of them added a font between the frames, the next frame drew the tile again and the test failed with left 2 or 3, right 1. Run with the tests in tests/fontchange.rs and tests/typeedges.rs, it failed in 23 of 30 runs on main. The test now holds fonts_steady() and takes its counts from with_fonts_unchanged(), as the other render tests that count kept work do. Each run builds a new document, so its first frame does not find a tile from an earlier run. Run with the same tests, it passed in all 30 runs. --- crates/render/src/pattern/tests.rs | 35 ++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/crates/render/src/pattern/tests.rs b/crates/render/src/pattern/tests.rs index 76aad7fe6f..701afe9b41 100644 --- a/crates/render/src/pattern/tests.rs +++ b/crates/render/src/pattern/tests.rs @@ -251,19 +251,30 @@ fn kept_tiles_stay_within_the_budget() { #[test] fn kept_tiles_count_the_images_they_could_not_decode() { use vectorcraft_doc::{ImageBlob, ImageObject}; - let mut d = doc_with_pattern(TileType::Grid); - // A PNG signature with nothing after it. - d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); - let im = ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; - d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))]; - let mut r = Renderer::new(); - let start = tiles_rasterized(); - for n in 0..3 { - let before = crate::paint::decode_failures(); - frame(&mut r, &d, 1.0); - assert!(crate::paint::decode_failures() > before, "frame {n}"); + // A tile of art with an image is drawn again when the fonts change, and other tests add + // fonts, so the frames come from a run in which the fonts stayed the same. Each run draws a + // new document, whose first frame finds no tile kept from an earlier run. + let _fonts = crate::tests_fontchange::fonts_steady(); + let (failures, rasterized) = crate::tests_fontchange::with_fonts_unchanged(|| { + let mut d = doc_with_pattern(TileType::Grid); + // A PNG signature with nothing after it. + d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); + let im = + ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))]; + let mut r = Renderer::new(); + let start = tiles_rasterized(); + let failures = [0, 1, 2].map(|_| { + let before = crate::paint::decode_failures(); + frame(&mut r, &d, 1.0); + crate::paint::decode_failures() > before + }); + (failures, tiles_rasterized() - start) + }); + for (n, failed) in failures.into_iter().enumerate() { + assert!(failed, "frame {n}"); } - assert_eq!(tiles_rasterized() - start, 1, "the later frames draw from the tile kept"); + assert_eq!(rasterized, 1, "the later frames draw from the tile kept"); } #[test]