diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 0f866c6588..fafcc12215 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -424,25 +424,45 @@ fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() { /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n{xa}0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n" ) }; + // Styles of one paint (`/SimpleStyle`): a red 2 pt stroke and no fill, and a gradient fill, + // which isn't read, so it is left out rather than listed with no paint. + let one_paint = |name: &str, paint: &str| format!("/KnownStyle :\n({name}) /Name ,\n/SimpleStyle :\n{paint}/Paint ;\n/Def ;\n"); let styles = format!( - "%AI9_BeginArtStyles\n{}{}{}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(Red)\n(Green)\n(Blue)\n%AI9_EndArtStyleList\n", + "%AI9_BeginArtStyles\n{}{}{}{}{}%AI9_EndArtStyles\n\ + %AI9_BeginArtStyleList\n(Red)\n(Green)\n(Blue)\n(Red outline)\n(Sky fill)\n%AI9_EndArtStyleList\n", style("Blue", "0 0 0 0 0 0 1 Xa\n"), style("Green", "0 0 0 0 0 1 0 Xa\n"), - style("Red", "0 0 0 0 1 0 0 Xa\n") + style("Red", "0 0 0 0 1 0 0 Xa\n"), + one_paint("Red outline", "0 R\n0 1 1 0 K\n0 J 0 j 2 w 10 M []0 d\n1 0 Xd\n"), + one_paint("Sky fill", "0 O\n0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n") ); let data = ai::editing_data(200.0, 100.0, &styles); let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"); let mut s = Session::new(); let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "fills.ai"}); let r = s.execute("graphicStyle.loadLibrary", &p).unwrap(); - assert_eq!((&r["name"], &r["count"]), (&json!("fills"), &json!(3)), "{r}"); - let lib = s.execute("graphicStyle.library", &json!({"library": r["library"]})).unwrap(); + assert_eq!((&r["name"], &r["count"]), (&json!("fills"), &json!(4)), "{r}"); + let library = r["library"].clone(); + let lib = s.execute("graphicStyle.library", &json!({"library": library})).unwrap(); let names: Vec<&str> = lib["styles"].as_array().unwrap().iter().map(|g| g["name"].as_str().unwrap()).collect(); - assert_eq!(names, ["Red", "Green", "Blue"], "{lib}"); + assert_eq!(names, ["Red", "Green", "Blue", "Red outline"], "{lib}"); + // The outline style strokes a rectangle red, 2 pt wide, and leaves it unfilled. + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()); + let r = s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "names": ["Red outline"], "apply": true, "ids": [id.0]})).unwrap(); + assert_eq!(r["added"], json!(["Red outline"]), "{r}"); + let doc = &s.doc().unwrap().doc; + let look = &doc.node(id).unwrap().appearance; + assert!(look.fill().is_none(), "{look:?}"); + let strokes: Vec<_> = look.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))).collect(); + assert_eq!(strokes.len(), 1, "{look:?}"); + let stroke = look.stroke().unwrap(); + assert_eq!((stroke.paint.color(), stroke.width), (Some(vectorcraft_color::Color::cmyk(0.0, 1.0, 1.0, 0.0)), 2.0), "{look:?}"); + assert_eq!(doc.graphic_style("Red outline").map(|g| &g.appearance), Some(&**look)); let r = open(&mut s, "fills.ai", &file, json!({})).unwrap(); let doc = &s.doc().unwrap().doc; let styles: Vec<(&str, u32)> = doc.graphic_styles.iter().map(|g| (g.name.as_str(), g.id)).collect(); - assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3)], "{r}"); + assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3), ("Red outline", 4)], "{r}"); } /// A `.ai` symbol library (symbols and their list, no layers) opens with its symbols, and Other diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 74818a7604..e3e72861b9 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -2009,15 +2009,21 @@ fn status_rank(status: &str) -> usize { /// holds them) or overlap an object that is, as Show DCS Transparency Interactions marks them. fn dcs_transparency(d: &Document) -> BTreeSet { /// Each visible DCS image's id, bounds and whether it is transparent, and the bounds of each - /// other visible object with its own transparency. - fn walk(d: &Document, n: &Node, held: bool, images: &mut Vec<(NodeId, Rect, bool)>, see_through: &mut Vec) { + /// other visible object with its own transparency; bounds are cut to `clip`, the bounds of the + /// clipping paths of the clip groups and layers holding the object. + fn walk(d: &Document, n: &Node, held: bool, clip: Option, images: &mut Vec<(NodeId, Rect, bool)>, see_through: &mut Vec) { if !n.visible { return; } let own = n.shows_own_transparency(); + // The part of the object that shows through `clip`; none when it is clipped away. + let shown = n.geometric_bounds().and_then(|b| match clip { + Some(c) => Some(c.intersect(b)).filter(|i| i.area() > 0.0), + None => Some(b), + }); match &n.kind { NodeKind::Image(im) if d.images.get(&im.key).is_some_and(ImageBlob::is_separated) => { - if let Some(b) = n.geometric_bounds() { + if let Some(b) = shown { images.push((n.id, b, held || own)); // A transparent DCS image is an object with its own transparency for the // others it overlaps. @@ -2026,16 +2032,25 @@ fn dcs_transparency(d: &Document) -> BTreeSet { } } } - _ if own => see_through.extend(n.geometric_bounds()), + _ if own => see_through.extend(shown), _ => {} } - for c in n.children().into_iter().flatten() { - walk(d, c, held || own, images, see_through); + let Some(children) = n.children() else { return }; + // A clip group's or clipping layer's first child clips the others. + let clip = match &n.kind { + NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. } => match children.first().and_then(|c| c.geometric_bounds()) { + Some(c) => Some(clip.map_or(c, |o| o.intersect(c))), + None => clip, + }, + _ => clip, + }; + for c in children { + walk(d, c, held || own, clip, images, see_through); } } let (mut images, mut see_through) = (vec![], vec![]); for l in &d.layers { - walk(d, l, false, &mut images, &mut see_through); + walk(d, l, false, None, &mut images, &mut see_through); } images .into_iter() diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs index 6c17f04c0d..c801401de0 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -773,6 +773,88 @@ fn dcs_transparency_marks_transparent_and_overlapped_dcs_images() { assert!(error(&mut s, "links.list", json!({ "dcsTransparency": "yes" })).contains("dcsTransparency"), "a flag that isn't true or false"); } +#[test] +fn dcs_transparency_looks_only_at_the_part_of_an_image_its_clipping_path_shows() { + let dir = temp_dir("transparency-clipped"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let group = NodeId(r["ids"][0].as_u64().unwrap()); + let image = image_of(&s, &r["ids"][0]).id; + let (clip, all) = { + let doc = &s.doc().unwrap().doc; + let NodeKind::Group { children, clip: true } = &doc.node(group).unwrap().kind else { panic!("a clipping group") }; + (children[0].geometric_bounds().unwrap(), doc.node(image).unwrap().geometric_bounds().unwrap()) + }; + assert!(all.x1 - clip.x1 > 2.0, "the file's clipping path shows the left half: {clip:?} of {all:?}"); + // A half-transparent square over the part of the image the clipping path hides. + let x = (clip.x1 + all.x1) / 2.0 - 1.0; + let over = s.execute("shape.rectangle", &json!({"x": x, "y": all.y0, "width": 2, "height": 2})).unwrap()["id"].as_u64().unwrap(); + s.execute("transparency.set", &json!({"ids": [over], "opacity": 50})).unwrap(); + let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true; + assert!(!marked(&mut s), "the square is over the hidden part only"); + s.execute("transparency.set", &json!({"ids": [over], "opacity": 100})).unwrap(); + let shown = s.execute("shape.rectangle", &json!({"x": clip.x0, "y": all.y0, "width": 2, "height": 2})).unwrap()["id"].as_u64().unwrap(); + s.execute("transparency.set", &json!({"ids": [shown], "opacity": 50})).unwrap(); + assert!(marked(&mut s), "a square over the part that shows"); +} + +#[test] +fn dcs_transparency_skips_a_transparent_object_its_own_clipping_paths_hide() { + let dir = temp_dir("transparency-clipped-over"); + let path = write(&dir, "pic.eps", &two_plates()); + // A session with the file placed, and the top-left corner of the image, inside the part its + // clipping path shows. + let placed = || { + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let b = image_of(&s, &r["ids"][0]).geometric_bounds().unwrap(); + (s, b.x0, b.y0) + }; + let rect = |s: &mut Session, x: f64, y: f64, width: f64| { + s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": width, "height": 2})).unwrap()["id"].as_u64().unwrap() + }; + let make = |s: &mut Session, ids: [u64; 2]| { + s.execute("select.set", &json!({ "ids": ids })).unwrap(); + s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap() + }; + let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true; + // A half-transparent square over the part of the image that shows. + let square = |s: &mut Session, x: f64, y: f64| { + let id = rect(s, x, y, 2.0); + s.execute("transparency.set", &json!({"ids": [id], "opacity": 50})).unwrap(); + assert!(marked(s), "the square over the image"); + id + }; + + // Its own clip group's clipping path is beside it. + let (mut s, x, y) = placed(); + let over = square(&mut s, x, y); + let beside = rect(&mut s, x + 5.0, y, 2.0); + make(&mut s, [over, beside]); + assert!(!marked(&mut s), "a clip group that hides the square"); + + // Its layer's clipping path is beside it. + let (mut s, x, y) = placed(); + s.execute("layer.new", &json!({})).unwrap(); + square(&mut s, x, y); + rect(&mut s, x + 5.0, y, 2.0); + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("layer.clippingMask.toggle", &json!({})).unwrap()["clip"].as_bool().unwrap()); + assert!(!marked(&mut s), "a clipping layer that hides the square"); + + // Each of two nested clipping paths shows part of the inner group, and only the inner one + // covers the square. + let (mut s, x, y) = placed(); + let over = square(&mut s, x, y); + let inner = rect(&mut s, x, y, 40.0); + let group = make(&mut s, [over, inner]); + assert!(marked(&mut s), "the inner clipping path shows the square"); + let outer = rect(&mut s, x + 10.0, y, 20.0); + make(&mut s, [group, outer]); + assert!(!marked(&mut s), "the two clipping paths together hide the square"); +} + #[test] fn dcs_transparency_counts_a_transparent_dcs_image_over_another() { let dir = temp_dir("transparency-two"); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 52be19a952..37f8c63acf 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4318,6 +4318,63 @@ proptest! { survive("style library in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; } + /// Graphic style libraries whose styles are one paint (`/SimpleStyle`): any paint operators in + /// any order, with any operands or none, `Xd` missing or repeated, and the paint left open. The + /// library is also loaded in the Graphic Style Libraries panel and its styles applied to artwork. + #[test] + fn ai_style_libraries_of_one_paint_styles_never_panic( + // Each operator, and whether it is a paint operator rather than any text. + ops in prop::collection::vec(prop_oneof![ + prop::sample::select(vec![ + "0 R", "0 O", "0 1 1 0 K", "1 0 0 0 k", "0 0 0 0 1 0 0 Xa", "0 0 0 0 1 0 0 XA", "0.5 G", "0 g", "K", "Xa", + "0 J 0 j 2 w 10 M []0 d", "-1 w", "1e400 w", "0 M", "[3 -1]0 d", "[]0 d", + "1 0 Xd", "0 1 Xd", "1 1 Xd", "0 0 Xd", "1 Xd", "Xd", "0 1 0 0 0 Xy", "1 0.5 1 1 0 Xy", "Xy", + "0 Bb", "2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg", "0 BB", "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P", "0 XR", + ]).prop_map(|op| (op.to_string(), true)), + "[ -~]{0,12}".prop_map(|text| (text, false)), + ], 0..12), + closed in any::(), + ) { + use vectorcraft_testkit::ai; + let close = if closed { "/Paint ;\n/Def ;\n" } else { "" }; + // C, a red 2 pt stroke, comes first, so that it reads whatever A holds. + let header = format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(C) /Name ,\n/SimpleStyle :\n0 R\n0 1 1 0 K\n2 w\n1 0 Xd\n/Paint ;\n/Def ;\n\ + /KnownStyle :\n(A) /Name ,\n/SimpleStyle :\n{}\n{close}/KnownStyle :\n(B) /Name ,\n/SimpleStyle :\n1 0 Xd\n/Paint ;\n/Def ;\n\ + %AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(A)\n(B)\n(C)\n%AI9_EndArtStyleList\n", + ops.iter().map(|(op, _)| op.as_str()).collect::>().join("\n") + ); + let library = ai::editing_data(200.0, 100.0, &header); + survive("one-paint style library", || Some(vectorcraft_eps::layered_ai(library.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + let file = ai::ai(library.as_bytes(), ai::page_pdf()); + // How many objects C's look reached, what applying it answered, and whether that look is + // C's red 2 pt stroke with no fill. + let (mut styled, mut applied, mut red_stroke) = (0, Value::Null, false); + survive("one-paint styles applied", || { + let mut s = rich_session(); + let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "fuzz.ai"}); + let library = no_panic(s.execute("graphicStyle.loadLibrary", &p)).ok()?["library"].clone(); + no_panic(s.execute("select.all", &json!({}))).ok()?; + // Every style is added and the first one, A when it reads, applied; then C is applied. + let _ = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "apply": true}))); + applied = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "names": ["C"], "apply": true}))).ok()?; + let d = (*s.doc().ok()?.doc).clone(); + if let Some(look) = applied["applied"].as_str().and_then(|name| d.graphic_style(name)).map(|g| &g.appearance) { + let strokes = look.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))).count(); + red_stroke = look.fill().is_none() + && strokes == 1 + && look.stroke().is_some_and(|s| (s.paint.color(), s.width) == (Some(vectorcraft_color::Color::cmyk(0.0, 1.0, 1.0, 0.0)), 2.0)); + d.walk(|n| styled += usize::from(n.appearance == *look)); + } + Some(d) + })?; + // Text that isn't a paint operator can leave a string or a comment open over the style list. + if ops.iter().all(|(_, paint)| *paint) { + prop_assert!(applied["applied"].is_string() && red_stroke && styled > 0, "the stroke style wasn't applied: {applied}"); + } + } + /// Objects on Path records of any shape: ids in any order, repeated or missing, any number of /// objects, pivots and angles out of range or not finite, spacings, order tags that disagree, /// groups nested or cut short, and versioned content left open. diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index bc58858b91..9f6a1dd6af 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1231,13 +1231,18 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p /// A graphic style's appearance from art style `st`, on no object: `None` when the style isn't one /// this reads, or paints with a gradient or a pattern (placed on each object as its drawn look has them). +/// A style that is one paint is its fill and stroke ([`crate::import::ai::style::ArtStyle::one_paint`]). pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) -> Option { use crate::import::ai::style::StyleItem; if !st.plain { return None; } + let style_items = match &st.one_paint { + Some(one) => one.as_ref().ok()?, + None => &st.items, + }; let mut items = vec![]; - for (n, item) in st.items.iter().enumerate() { + for (n, item) in style_items.iter().enumerate() { let mut layer = match item { StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())), // A graphic style's brush isn't checked against the file's brushes: it is left out. diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 6a75837733..482c172386 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -32,6 +32,10 @@ //! gradient (`Bb` … `flag (name) x y angle length … Bg` … `BB`); `stroke fill Xd` says whether it //! paints at all. A blend filter's `mode opacity isolate knockout shape Xy` is its transparency. //! +//! A style with no filters is one paint (`/SimpleStyle :` … `/Paint ;` in place of `/ActiveStyle`): +//! the fill's and stroke's colors and the stroke's style as an object's own paint operators write +//! them, `stroke fill Xd` turning each on, and an `Xy` for its transparency. +//! //! A stroke with a Calligraphic or Art brush is its own basic filter, `(Adobe Calligraphic Brush //! Tool)` or `(Adobe ArtOnPath Brush Tool)`, with its stroke's style and the brush's settings for //! the object as entries (see [`super::brush`]). @@ -206,6 +210,11 @@ pub(super) struct ArtStyle { /// paint is a pattern: its [`StyleItem::Pattern`]. A text document's characters name such /// styles for their fills and strokes (`ate`). `None` for any other paint or style. pub simple_pattern: Option, + /// A style that is one paint: its fill and stroke, bottom first, each there when its + /// `stroke fill Xd` turns it on (none when neither is on). `Err` when it turns on a fill or + /// stroke whose paint isn't read here (a gradient, a pattern, a color that isn't read) or has + /// no `Xd`. `None` for a style with filters, whose paint is [`Self::items`]. + pub one_paint: Option, ()>>, } impl ArtStyle { @@ -412,12 +421,22 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap> = None; - let mut finish = |name: Option, filters: &mut Vec, simple_pattern: Option| { + // The same style's paint operators so far. + let mut one_paint: Option = None; + let mut finish = |name: Option, filters: &mut Vec, simple_pattern: Option, one_paint: Option| { if let Some(name) = name { // Only a style that is one paint holds a pattern for type: one with filters as well // paints with more than that. let simple_pattern = simple_pattern.filter(|_| filters.is_empty()); - styles.insert(name, ArtStyle { simple_pattern, ..style(filters) }); + let mut st = ArtStyle { simple_pattern, ..style(filters) }; + if let Some(one) = one_paint.filter(|_| filters.is_empty()) { + if let Some((mode, opacity)) = one.paint.xy { + let (isolate, knockout) = one.paint.xy_group.unwrap_or((false, Knockout::Off)); + st.transparency = Some((opacity, super::read::blend_mode(mode), isolate, knockout)); + } + st.one_paint = Some(one.items()); + } + styles.insert(name, st); } filters.clear(); }; @@ -454,12 +473,12 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap match n { b"KnownStyle" => { - finish(name.take(), &mut filters, simple.take().flatten()); + finish(name.take(), &mut filters, simple.take().flatten(), one_paint.take()); operands.clear(); open.clear(); (pending, in_basic, style_entry, other_entry) = (None, false, None, None); } - b"SimpleStyle" if filters.is_empty() => simple = Some(None), + b"SimpleStyle" if filters.is_empty() => (simple, one_paint) = (Some(None), Some(OnePaint::default())), b"FillStyle" => style_entry = Some(Kind::Fill), b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other), b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(|v| names.text(v)), @@ -559,7 +578,7 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap HashMap Option<(String, Option<[f6 } /// A paint operator in a style being read: one of the last filter's (`filters`), else one of a style -/// that is one paint (`simple`, while one is read). -fn style_paint_op(filters: &mut [Filter], simple: &mut Option>, op: &str, vals: &[V], names: &mut Names) { +/// that is one paint (`simple` and `one_paint`, while one is read). +fn style_paint_op( + filters: &mut [Filter], + simple: &mut Option>, + one_paint: &mut Option, + op: &str, + vals: &[V], + names: &mut Names, +) { if let Some(f) = filters.last_mut() { paint_op(f, op, vals, names); - } else if let Some(paint) = simple.as_mut() { + return; + } + if let Some(paint) = simple.as_mut() { simple_paint_op(paint, op, vals, names); } + if let Some(one) = one_paint.as_mut() { + one.op(op, vals, names); + } +} + +/// The paint operators of a style that is one paint (`/SimpleStyle :` … `/Paint ;`), read as a +/// fill's or stroke's style reads them. +#[derive(Default)] +struct OnePaint { + paint: Filter, + /// Its `stroke fill Xd` was read. + xd: bool, + /// Its last fill paint is a pattern (`p`) or may be a gradient (`Bg`). + fill_unread: bool, + /// Its last stroke paint is a pattern (`P`) or may be a gradient (`Bg`). + stroke_unread: bool, + /// Inside a gradient instance (`flag Bb` … `BB`): whether it paints the stroke (flag 1). + instance: Option, +} + +impl OnePaint { + fn op(&mut self, op: &str, vals: &[V], names: &mut Names) { + match op { + "Xd" => self.xd |= vals.iter().filter_map(V::num).count() >= 2, + "p" => self.fill_unread = true, + "P" => self.stroke_unread = true, + // The flag of the instance it is in says which one it paints, as an object's does + // (`Instance::begin` in `paint.rs`); outside one it is read as either one's. + "Bb" => self.instance = Some(vals.iter().filter_map(V::num).next_back() == Some(1.0)), + "BB" => self.instance = None, + "Bg" => match self.instance { + Some(true) => self.stroke_unread = true, + Some(false) => self.fill_unread = true, + None => (self.fill_unread, self.stroke_unread) = (true, true), + }, + // A color replaces what came before it; one that isn't read leaves none. + "Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "xa" | "xx" | "xk" | "xs" | "xz" => { + (self.fill_unread, self.paint.fill) = (false, None); + } + "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" => (self.stroke_unread, self.paint.stroke) = (false, None), + _ => {} + } + paint_op(&mut self.paint, op, vals, names); + } + + /// Its fill and stroke ([`ArtStyle::one_paint`]). + fn items(&self) -> Result, ()> { + let f = &self.paint; + if !self.xd { + return Err(()); + } + let (stroked, filled) = f.paints; + let mut items = vec![]; + if filled { + match &f.fill { + Some(p) if !self.fill_unread => items.push(StyleItem::Fill(p.clone())), + _ => return Err(()), + } + } + if stroked { + match &f.stroke { + Some(p) if !self.stroke_unread => items.push(StyleItem::Stroke { + paint: p.clone(), + width: f.width, + cap: f.cap, + join: f.join, + miter: f.miter, + dash: f.dash.clone(), + profile: None, + align: StrokeAlign::Center, + brush: None, + }), + _ => return Err(()), + } + } + Ok(items) + } } /// A paint operator of a style that is one paint: a pattern (`p`) is kept as its diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index c6debd42f9..ea8428809c 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1512,3 +1512,48 @@ fn only_a_style_of_one_pattern_paint_holds_a_pattern_for_type() { assert!(styles(&body).get("S").is_none_or(|s| s.simple_pattern.is_none()), "{body}"); } } + +/// A style that is one paint reads as the fill and stroke its `Xd` turns on, in the paint's colors +/// and the stroke's style; one that turns on a paint not read here, or has no `Xd`, isn't read. +#[test] +fn a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on() { + let one = |paint: &str| { + let data = format!("/KnownStyle :\n(S) /Name ,\n/SimpleStyle :\n{paint}/Paint ;\n/Def ;\n"); + art_styles_in(data.as_bytes(), &mut Names::default()).remove("S").and_then(|s| s.one_paint) + }; + let stroke = "0 R\n0 1 1 0 K\n1 J 2 j 3 w 4 M [2 1]0 d\n"; + let Some(Ok(items)) = one(&format!("0 O\n0 0 1 0 k\n{stroke}1 1 Xd\n")) else { panic!("{:?}", one(stroke)) }; + let [StyleItem::Fill(fill), StyleItem::Stroke { paint, width, cap, join, miter, dash, profile: None, align: StrokeAlign::Center, brush: None }] = + items.as_slice() + else { + panic!("{items:?}") + }; + assert_eq!((fill.color(), paint.color()), (Some(Color::cmyk(0.0, 0.0, 1.0, 0.0)), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0)))); + assert_eq!((*width, *cap, *join, *miter), (3.0, LineCap::Round, LineJoin::Bevel, 4.0)); + assert_eq!(dash.as_ref().map(|d| d.pattern.clone()), Some(vec![2.0, 1.0])); + // Only what `Xd` turns on; nothing when it turns on neither. + assert!(matches!(one(&format!("0 0 1 0 k\n{stroke}1 0 Xd\n")), Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Stroke { .. }]))); + assert_eq!(one(&format!("0 0 1 0 k\n{stroke}0 0 Xd\n")), Some(Ok(vec![]))); + // The last paint of each one counts: a solid color after a pattern or a gradient replaces it. + let solid_fill = |before: &str| one(&format!("{before}\n0 0 1 0 k\n0 1 Xd\n")); + for before in ["(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", "0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB"] { + assert!(matches!(solid_fill(before), Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Fill(_)])), "{before}"); + } + let solid_stroke = one("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P\n0 1 1 0 K\n1 0 Xd\n"); + assert!(matches!(solid_stroke, Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Stroke { .. }]))); + // A gradient instance's flag says which one it paints: a stroke gradient leaves a solid fill read. + let fill_and_stroke_gradient = one("0 0 1 0 k\n1 Bb\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 1 Xd\n"); + assert!(matches!(fill_and_stroke_gradient, Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Fill(_)]))); + // Not read: no `Xd`, a gradient or pattern fill, a pattern stroke, a gradient stroke after a solid one, a paint + // turned on with no color. + for paint in [ + format!("0 0 1 0 k\n{stroke}"), + "0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n".to_string(), + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 Xd\n".to_string(), + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P\n1 0 Xd\n".to_string(), + "0 R\n0 1 1 0 K\n1 Bb\n0 0 0 0 Bh\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n2 w\n1 0 Xd\n".to_string(), + "0 0 1 0 k\n1 1 Xd\n".to_string(), + ] { + assert_eq!(one(&paint), Some(Err(())), "{paint}"); + } +} diff --git a/crates/trace/src/centerline.rs b/crates/trace/src/centerline.rs index 332c4499c3..852bdb1858 100644 --- a/crates/trace/src/centerline.rs +++ b/crates/trace/src/centerline.rs @@ -4,7 +4,10 @@ //! 1. **Distance**: an exact Euclidean distance transform in each blob crop gives each pixel //! its distance to the nearest pixel outside the blob. //! 2. **Lines**: an 8-connected component whose widest point (twice its largest distance, less -//! one) is within the stroke width is a line; wider ones stay filled areas. +//! one) is within the stroke width is a line. A wider one keeps as its area every disc wider +//! than the stroke width that fits in it; each piece outside those discs that reaches out of +//! them at least one and a half times its own width (and 3 px) is a thin feature of it, and a +//! line, whose end at the area goes to the cut, its cap reaching into the area. //! 3. **Skeleton**: Guo–Hall thins the crop to one pixel, preserving diagonal ends, then //! staircase corners that don't connect anything are dropped. //! 4. **Graph**: runs of skeleton pixels between ends and junctions (touching junction pixels @@ -93,20 +96,111 @@ pub(crate) fn lines( cells_left = left; let Some(dt) = distance::squared(&g.on, g.w, g.h) else { continue }; let widest = 2.0 * f64::from(dt.iter().copied().fold(0.0f32, f32::max)).sqrt() - 1.0; - if widest > max_width { - continue; - } - let Some(line) = trace_line(g, &dt, comp.len(), max_width, img, ink, style) else { continue }; - for &i in &comp { - if let Some(v) = thin.get_mut(i) { - *v = true; + let found = if widest > max_width { + // A wider shape keeps its wide part as an area; its thin features are lines. + arm_lines(&g, &dt, w, max_width, img, ink, style, &mut cells_left) + } else { + trace_line(g, &dt, comp.len(), max_width, img, ink, style, None).map(|line| (comp, line)).into_iter().collect() + }; + for (pixels, line) in found { + for &i in &pixels { + if let Some(v) = thin.get_mut(i) { + *v = true; + } } + out.push(line); } - out.push(line); } (thin, out) } +/// The thin features of component `g` (wider than `max_width` somewhere; `dt` its distances), traced +/// as lines, each with its pixels (indices of the `w`-wide image). A feature that cannot be traced, or +/// past the work left in `cells_left`, stays part of the area. +#[allow(clippy::too_many_arguments)] +fn arm_lines( + g: &Grid, + dt: &[f32], + w: usize, + max_width: f64, + img: &Raster, + ink: [f64; 3], + style: &Style, + cells_left: &mut usize, +) -> Vec<(Vec, Line)> { + let mut out = vec![]; + for arm in arms(g, dt, w, max_width).unwrap_or_default() { + let pixels: Vec = arm.iter().map(|&(i, _)| i).collect(); + let Some(ag) = Grid::from_component(&pixels, w) else { continue }; + let Some(left) = cells_left.checked_sub(ag.w.saturating_mul(ag.h)) else { break }; + *cells_left = left; + let mut cut = vec![false; ag.on.len()]; + for &(i, at_cut) in &arm { + if let Some(c) = ag.index(i, w).and_then(|k| cut.get_mut(k)) { + *c = at_cut; + } + } + let Some(adt) = distance::squared(&ag.on, ag.w, ag.h) else { continue }; + if 2.0 * f64::from(adt.iter().copied().fold(0.0f32, f32::max)).sqrt() - 1.0 > max_width { + continue; + } + if let Some(line) = trace_line(ag, &adt, pixels.len(), max_width, img, ink, style, Some(&cut)) { + out.push((pixels, line)); + } + } + out +} + +/// The thin features of component `g`, which is wider than `max_width` somewhere (`dt` its squared +/// distances to the paper): the pieces of it that lie outside every disc wider than `max_width` +/// that fits in it (the component opened by such a disc) and reach out of those discs at least one +/// and a half times their own width (and 3 px). The rest, with the slivers and corners the discs +/// leave, is the area. Each piece is a list of its pixels (indices of the `w`-wide image), each +/// marked when it touches the area (where the line is cut from it). +fn arms(g: &Grid, dt: &[f32], w: usize, max_width: f64) -> Option>> { + let r = (max_width + 1.0) / 2.0; + let seeds: Vec = dt.iter().map(|&d| f64::from(d) > r * r).collect(); + let near = distance::to_seeds(&seeds, g.w, g.h)?; + drop(seeds); + // (Half a pixel more than the discs' radius, so pixels the discs cut through are area.) + let cover = r + 0.5; + let rest = |i: usize| g.on.get(i) == Some(&true) && near.get(i).is_some_and(|&d| f64::from(d) > cover * cover); + let mut seen = vec![false; g.on.len()]; + let (mut out, mut stack) = (vec![], vec![]); + for start in g.interior() { + if !rest(start) || seen.get(start).copied().unwrap_or(true) { + continue; + } + let mut piece = vec![]; + *seen.get_mut(start)? = true; + stack.push(start); + while let Some(i) = stack.pop() { + let mut at_cut = false; + for j in g.neighbours(i) { + if !rest(j) { + at_cut = true; + } else if !seen.get(j).copied().unwrap_or(true) { + *seen.get_mut(j)? = true; + stack.push(j); + } + } + piece.push((i, at_cut)); + } + let deepest = piece.iter().filter_map(|&(i, _)| dt.get(i)).copied().fold(0.0f32, f32::max); + let width = (2.0 * f64::from(deepest).sqrt() - 1.0).max(1.0); + let farthest = piece.iter().filter_map(|&(i, _)| near.get(i)).copied().fold(0.0f32, f32::max); + if f64::from(farthest).sqrt() - cover < (1.5 * width).max(3.0) { + continue; + } + let image = |i: usize| { + let (x, y) = (usize::try_from(g.x0 + (i % g.w) as i64).ok()?, usize::try_from(g.y0 + (i / g.w) as i64).ok()?); + y.checked_mul(w)?.checked_add(x) + }; + out.push(piece.into_iter().map(|(i, at_cut)| Some((image(i)?, at_cut))).collect::>>()?); + } + Some(out) +} + /// The 8 neighbours of pixel `i` inside the `w` × `h` image. fn neighbours(i: usize, w: usize, h: usize) -> impl Iterator { let (x, y) = ((i % w) as i64, (i / w) as i64); @@ -163,6 +257,23 @@ impl Grid { let comp: Vec<_> = mask.iter().enumerate().filter_map(|(i, &on)| on.then_some(i)).collect(); Self::from_component(&comp, w) } + /// The grid index of pixel `i` of the `image_width`-wide image, when the grid holds it. + fn index(&self, i: usize, image_width: usize) -> Option { + let (x, y) = (i64::try_from(i % image_width.max(1)).ok()? - self.x0, i64::try_from(i / image_width.max(1)).ok()? - self.y0); + let (x, y) = (usize::try_from(x).ok()?, usize::try_from(y).ok()?); + (x < self.w && y < self.h).then(|| y * self.w + x) + } + /// Whether a pixel `cut` marks (`cut` is laid out as the grid) has its center within `radius` of + /// `p` (image coordinates). + fn near_cut(&self, cut: &[bool], p: Point, radius: f64) -> bool { + if !p.x.is_finite() || !p.y.is_finite() || !radius.is_finite() { + return false; + } + let (cx, cy) = (p.x - self.x0 as f64 - 0.5, p.y - self.y0 as f64 - 0.5); + let span = |c: f64, n: usize| ((c - radius).floor().max(0.0) as usize, ((c + radius).ceil().max(0.0) as usize).min(n.saturating_sub(1))); + let ((x0, x1), (y0, y1)) = (span(cx, self.w), span(cy, self.h)); + (y0..=y1).any(|y| (x0..=x1).any(|x| (x as f64 - cx).hypot(y as f64 - cy) <= radius && cut.get(y * self.w + x).copied().unwrap_or(false))) + } fn offsets(&self) -> [isize; 8] { let w = self.w as isize; [-w, -w + 1, 1, w + 1, w, w - 1, -1, -w - 1] @@ -193,8 +304,20 @@ impl Grid { } } -/// The centre lines of one line component (pixel indices of the `w`-wide image). -fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Raster, ink: [f64; 3], style: &Style) -> Option { +/// The center lines of one line component (pixel indices of the `w`-wide image). `cut`, for a thin +/// feature of a wider shape, marks (in `g`) its pixels that touch the shape's area: an end there is +/// not an end of the ink, and goes to the cut, its cap reaching into the area. +#[allow(clippy::too_many_arguments)] +fn trace_line( + mut g: Grid, + dist: &[f32], + pixels: usize, + max_width: f64, + img: &Raster, + ink: [f64; 3], + style: &Style, + cut: Option<&[bool]>, +) -> Option { thin::thin(&mut g); let mut graph = graph::build(&g, dist)?; graph.simplify(max_width)?; @@ -268,7 +391,9 @@ fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Ra // to a few degrees at best, less without anti-aliasing, and an end already on it is // better left where it is.) let before = inner - *r.pts.get(at(2 * back))?; - let wide = width >= 3.0 && n > 2 * back && before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot(); + // (At a cut from a wider shape, where the skeleton turns along the cut.) + let joined = cut.is_some_and(|cut| g.near_cut(cut, tip, width / 2.0 + 1.5)); + let wide = width >= 3.0 && n > 2 * back && (joined || before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot()); let d = if wide { before } else { tip - inner }; if d.hypot() < 1e-9 { continue; @@ -282,6 +407,11 @@ fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Ra } } let tip = on_line(tip); + if joined { + // The skeleton stops about half a width short of the cut. + *r.pts.get_mut(at(0))? = tip + along * (width / 2.0 + 0.5); + continue; + } let reach = ink.beyond(tip, along); if width >= 3.0 { let aside = kurbo::Vec2::new(-along.y, along.x) * (0.35 * width); diff --git a/crates/trace/src/centerline/distance.rs b/crates/trace/src/centerline/distance.rs index d357c7d681..8e7f595dd4 100644 --- a/crates/trace/src/centerline/distance.rs +++ b/crates/trace/src/centerline/distance.rs @@ -40,7 +40,21 @@ pub(super) fn squared(mask: &[bool], w: usize, h: usize) -> Option> { if mask.len() != size || mask.iter().enumerate().any(|(i, &on)| on && (i < w || i >= size - w || i % w == 0 || i % w + 1 == w)) { return None; } - let mut grid: Vec = mask.iter().map(|&on| if on { FAR } else { 0.0 }).collect(); + two_passes(mask.iter().map(|&on| if on { FAR } else { 0.0 }).collect(), w, h) +} + +/// Squared distances from each pixel of a validated crop to the nearest `seeds` pixel (a very large +/// value everywhere when there is none). +pub(super) fn to_seeds(seeds: &[bool], w: usize, h: usize) -> Option> { + let size = super::crop_size(w, h)?; + if seeds.len() != size { + return None; + } + two_passes(seeds.iter().map(|&seed| if seed { 0.0 } else { FAR }).collect(), w, h) +} + +/// The column pass and then the row pass over `grid` (0 at a seed, `FAR` elsewhere). +fn two_passes(mut grid: Vec, w: usize, h: usize) -> Option> { let n = w.max(h); let (mut f, mut d, mut envelope) = (vec![0.0; n], vec![0.0; n], Vec::with_capacity(n)); for x in 0..w { @@ -97,6 +111,38 @@ mod tests { } } + #[test] + fn distances_to_seeds_equal_brute_force() { + let mut seed = 7u64; + let mut rnd = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + (seed >> 33) as usize + }; + for case in 0..100 { + let (w, h) = (3 + rnd() % 20, 3 + rnd() % 20); + let seeds: Vec = (0..w * h).map(|_| rnd() % 9 == 0).collect(); + let d = to_seeds(&seeds, w, h).unwrap(); + for (i, &actual) in d.iter().enumerate() { + let expected = seeds + .iter() + .enumerate() + .filter(|&(_, &s)| s) + .map(|(j, _)| { + let dx = (i % w).abs_diff(j % w); + let dy = (i / w).abs_diff(j / w); + (dx * dx + dy * dy) as f32 + }) + .min_by(f32::total_cmp); + match expected { + Some(e) => assert_eq!(actual, e, "case {case}, pixel {i}"), + None => assert!(actual > 1e18, "case {case}: no seed, pixel {i} at {actual}"), + } + } + } + assert!(to_seeds(&[true; 8], 3, 3).is_none()); + assert!(to_seeds(&[], 2, 2).is_none()); + } + #[test] fn invalid_crops_are_refused() { assert!(squared(&[], 0, 0).is_none()); diff --git a/crates/trace/src/params.rs b/crates/trace/src/params.rs index 6073fa04fe..a6676cd696 100644 --- a/crates/trace/src/params.rs +++ b/crates/trace/src/params.rs @@ -114,7 +114,8 @@ pub struct TraceParams { pub gradients: bool, /// Create: trace areas as filled shapes. pub fills: bool, - /// Create: trace lines no wider than `stroke_width` as stroked centre lines. + /// Create: trace lines no wider than `stroke_width`, and such thin features of wider shapes, as + /// stroked center lines. pub strokes: bool, /// Stroke: the widest feature (px) traced as a stroke. pub stroke_width: f64, diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index f6176cdd6f..d0a589b916 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -932,6 +932,66 @@ fn wide_areas_stay_filled_and_lines_cross_at_junctions() { assert!(res.paths.iter().any(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed), "the disc's outline"); } +#[test] +fn thin_features_of_a_wide_shape_are_strokes_and_its_body_a_fill() { + // A disc (r = 20) with a 3 px wide arm from it out to x = 140 and a 5 px wide one up to y = 5. + let img = Raster::from_fn(160, 100, |x, y| { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let disc = (px - 50.0).hypot(py - 60.0) <= 20.0; + let across = (py - 60.0).abs() <= 1.5 && (50.0..=140.0).contains(&px); + let up = (px - 50.0).abs() <= 2.5 && (5.0..=60.0).contains(&py); + if disc || across || up { BLACK } else { WHITE } + }); + let res = trace(&img, &strokes(true)); + let fills: Vec<_> = res.paths.iter().filter(|p| p.stroke.is_none()).collect(); + assert_eq!(fills.len(), 1, "the disc"); + let a = net_area(&fills[0].path); + assert!((a - std::f64::consts::PI * 400.0).abs() < 90.0, "disc area {a}"); + let b = fills[0].path.bounds().unwrap(); + assert!(b.x1 < 76.0 && b.y0 > 34.0, "the fill stays the disc: {b:?}"); + let lines = stroked(&res); + assert_eq!(lines.len(), 2, "{:?}", lines.iter().map(|(p, s)| (p.stroke, s.anchors.len())).collect::>()); + let ends = |s: &vectorcraft_geom::SubPath| (s.anchors[0].p, s.anchors[s.anchors.len() - 1].p); + let (across, up) = if ends(lines[0].1).0.y.min(ends(lines[0].1).1.y) > 50.0 { (lines[0], lines[1]) } else { (lines[1], lines[0]) }; + // The arm across: its free end where the bar ends (less its cap), its other end at the disc, the + // cap closing the join. + let w = across.0.stroke.unwrap(); + assert!((2.0..=4.0).contains(&w), "width {w}"); + let (p, q) = ends(across.1); + let (near, far) = if p.x < q.x { (p, q) } else { (q, p) }; + assert!(far.distance(Point::new(140.0, 60.0)) < 4.0, "free end {far:?}"); + assert!((60.0..=72.5).contains(&near.x) && (near.y - 60.0).abs() < 1.5 && near.x - w / 2.0 <= 70.5, "joined end {near:?}"); + // The arm up, 5 px wide. + let w = up.0.stroke.unwrap(); + assert!((4.0..=6.0).contains(&w), "width {w}"); + let (p, q) = ends(up.1); + let (top, low) = if p.y < q.y { (p, q) } else { (q, p) }; + assert!(top.distance(Point::new(50.0, 5.0)) < 4.0, "free end {top:?}"); + assert!((38.0..=48.0).contains(&low.y) && (low.x - 50.0).abs() < 1.5 && low.y + w / 2.0 >= 40.0 - 0.5, "joined end {low:?}"); + // Strokes only: the disc is outlined and both arms stay strokes. + let res = trace(&img, &strokes(false)); + assert!(res.paths.iter().all(|p| p.stroke.is_some())); + assert_eq!(res.paths.iter().filter(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed).count(), 1, "the disc's outline"); + assert_eq!(stroked(&res).iter().filter(|(p, _)| p.stroke.is_some_and(|w| w > 1.5)).count(), 2, "the two arms"); +} + +#[test] +fn wide_shapes_without_thin_features_stay_one_fill() { + // A square, a diamond and a disc: their corners and edges are not thin features. + let img = Raster::from_fn(240, 90, |x, y| { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let square = (10.0..=70.0).contains(&px) && (15.0..=75.0).contains(&py); + let diamond = (px - 120.0).abs() + (py - 45.0).abs() <= 35.0; + let disc = (px - 200.0).hypot(py - 45.0) <= 30.0; + if square || diamond || disc { BLACK } else { WHITE } + }); + for width in [3.0, 6.0, 12.0] { + let res = trace(&img, &TraceParams { stroke_width: width, ..strokes(true) }); + assert_eq!(res.paths.len(), 3, "width {width}: {:?}", res.paths.iter().map(|p| p.stroke).collect::>()); + assert!(res.paths.iter().all(|p| p.stroke.is_none()), "width {width}"); + } +} + #[test] fn create_settings_parse_with_defaults() { let p: TraceParams = serde_json::from_value(serde_json::json!({ "strokes": true, "strokeWidth": 4 })).unwrap(); diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cc871ad8a3..8a8f8453b6 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3538,11 +3538,11 @@ digits Malayalam Malayalam Tate-chu-yoko Tate-chu-yoko Tate-chu-yoko Settings… Inställningar för Tate-chu-yoko… Tate-chu-yoko Settings Inställningar för Tate-chu-yoko - Composite Fonts… Sammansatta teckensnitt… - Composite Fonts Sammansatta teckensnitt - Export Composite Fonts Exportera sammansatta teckensnitt - Import Composite Fonts Importera sammansatta teckensnitt - Delete Font Ta bort teckensnitt + Composite Fonts… Sammansatta typsnitt… + Composite Fonts Sammansatta typsnitt + Export Composite Fonts Exportera sammansatta typsnitt + Import Composite Fonts Importera sammansatta typsnitt + Delete Font Ta bort typsnitt Kanji Kanji Kana Kana Numerals Siffror @@ -5386,7 +5386,7 @@ lighting Light Ljus @msg {_1}: the clipping path is too complex to cut the art with {_1}: urklippsbanan är för komplex för att klippa teckningen med @msg {_1} takes at most {MAX_MEMBERS} objects {_1} tar högst {MAX_MEMBERS} objekt @msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} kan inte bygga solider av teckning som är kapslad mer än {MAX_DEPTH} nivåer djupt -@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Färgen för Rotera runt axel är för komplex för den här ytan; minska Segment eller förenkla övertoningen +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Färgen för Rotera runt axel är för komplex för den här ytan; minska Segment eller förenkla gradienten @msg Revolve edge must be left or right Rotera runt axel: edge måste vara left eller right @msg Revolve cap must be a boolean Rotera runt axel: cap måste vara ett booleskt värde @msg the revolved object has no fill or stroke det roterade objektet har ingen fyllning eller linje @@ -6473,8 +6473,8 @@ axis Y Axis Y-axel @msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte @msg unknown permission `{shown}` okänd behörighet `{shown}` @msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants ”{name}” begär behörigheterna {asked}: en person måste godkänna dem i skrivbordsappen, eller skicka `grant` i en session som startats med --allow-plugin-grants -@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` och `allowHeavy` gäller bara i en session som startats med --allow-plugin-grants: godkänn insticksprogrammet i skrivbordsappen -@msg Only the person at the keyboard can approve installing a plug-in Bara personen vid tangentbordet kan godkänna installationen av ett insticksprogram +@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` och `allowHeavy` gäller bara i en session som startats med --allow-plugin-grants: godkänn pluginet i skrivbordsappen +@msg Only the person at the keyboard can approve installing a plug-in Bara personen vid tangentbordet kan godkänna installationen av ett plugin @msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” beviljades inte `{p}`, som dess utdata behöver: ingenting ändrades @msg a heavy filter runs in the background, so it can't declare `commands` ett tungt filter körs i bakgrunden, så det kan inte deklarera `commands` @msg at most {MAX_COMMANDS} `commands` högst {MAX_COMMANDS} `commands` @@ -6579,7 +6579,7 @@ axis Y Axis Y-axel Skill Icons Färdighetsikoner Attack Attack Defend Försvar - Heal Läkning + Heal Retuschering Buff Buff Ult Ult Sword Svärd @@ -8802,3 +8802,40 @@ digits Hindi Hindi Margin Marginal Box Ram Swatch Färgruta + Duplicate Effects Duplicera effekter + Remove Effects Ta bort effekter + Add art with Add as Single Graphic, or drop art here. Lägg till grafik med Lägg till som enkel grafik, eller släpp grafik här. + Add as Single Graphic Lägg till som enkel grafik + Add to Custom Dictionary Lägg till i egen ordlista + Assamese assamesiska + Bulgarian bulgariska + Cancel Opening Avbryt öppning + Change in Custom Dictionary Ändra i egen ordlista + Delete from Custom Dictionary Radera från egen ordlista + Delete SVG Filter Radera SVG-filter + Edit SVG Filter Redigera SVG-filter + Expand as Wireframes Expandera som trådmodeller + Face Yta + Graphics map onto planar faces: pick the Plane or Extrude object type. Grafik mappas på plana ytor: välj objekttypen Plan eller Extrudera. + Import SVG Filter Importera SVG-filter + Import SVG Filter… Importera SVG-filter… + Line Gap Radmellanrum + List Installed Scripts Lista installerade skript + Manage Profiles Hantera profiler + Mapped graphics Mappad grafik + Materials Material + Move Type Path Handle Flytta handtag för textbana + NEW DOCUMENT PROFILES PROFILER FÖR NYTT DOKUMENT + New SVG Filter Nytt SVG-filter + Pick a graphic above to map it onto the object. Välj en grafik ovan för att mappa den på objektet. + Reading the file… Läser filen… + Remove Graphic Ta bort grafik + Replace the selected 3D objects with the edges of their faces, as stroked paths. Ersätt de markerade 3D-objekten med kanterna på deras ytor, som banor med kontur. + Sanskrit sanskrit + Select a 3D object to map the graphic onto it. Markera ett 3D-objekt för att mappa grafiken på det. + Select Symbol Instance Part Markera del av symbolinstans + Spelling Suggestions Stavningsförslag + Stroke Weight Down Minska konturtjocklek + Stroke Weight Up Öka konturtjocklek + The selected objects have different graphics. De markerade objekten har olika grafik. + Word Gap Ordmellanrum diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index 0c702636ec..fe38851b44 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -465,9 +465,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let sel = selected(ui.ctx(), &doc); let view: View = pstate(ui.ctx(), "gs-view"); let mut ev = Events::default(); + // In a sized panel the list fills the space above the footer (#2992), as the Artboards list + // does; elsewhere it keeps its 220 pt. + let fixed = widgets::panel_footer_bottom(ui).is_some(); + let list_height = widgets::panel_list_height(ui, 220.0, 2.0 + ui.spacing().item_spacing.y); widgets::list_box(ui, |ui| { - let out = egui::ScrollArea::vertical().id_salt("gs-scroll").max_height(220.0).show(ui, |ui| { - ui.set_min_height(120.0); + let scroll = egui::ScrollArea::vertical() + .id_salt("gs-scroll") + .max_height(list_height) + .min_scrolled_height(list_height.min(64.0)) + .auto_shrink([false, !fixed]); + let out = scroll.show(ui, |ui| { + ui.set_min_height(if fixed { list_height.min(120.0) } else { 120.0 }); ui.set_width(ui.available_width()); if doc.graphic_styles.is_empty() { super::empty_state(ui, "dc-graphic-styles", tl!("No graphic styles"), tl!("Select styled art and click New Graphic Style.")); @@ -847,6 +856,36 @@ mod tests { app.session.active().unwrap().doc.graphic_styles.iter().map(|g| g.name.clone()).collect() } + /// In a panel made taller the list fills the space above its footer instead of staying 220 pt + /// tall with blank space below it (#2992); outside a sized panel it keeps that height. + #[test] + fn the_list_fills_a_tall_panel_above_its_footer() { + let mut app = app(); + for i in 0..30 { + app.session.execute("graphicStyle.new", &json!({"name": format!("Style {i:02}")})).unwrap(); + } + let ctx = egui::Context::default(); + set_pstate(&ctx, "gs-view", View::LargeList); + // The clip rect of the first style's name: the list's visible area. + let mut list = |bottom: Option| { + let mut clip = None; + for _ in 0..3 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(260.0, 700.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let info = egui::UiStackInfo::default().with_tag_value(widgets::PANEL_FOOTER, bottom); + ui.scope_builder(egui::UiBuilder::new().ui_stack_info(info), |ui| show(&mut app, ui)); + }); + out.textures_delta.clear(); + clip = out.shapes.iter().find(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == "Style 00")).map(|c| c.clip_rect); + } + clip.expect("the first style's row is drawn") + }; + let tall = list(Some(650.0)); + assert!(tall.height() > 400.0 && tall.bottom() <= 650.0 - 31.0, "the list fills the panel above its footer: {tall:?}"); + let loose = list(None); + assert!(loose.height() <= 220.5, "unsized, the list keeps its height: {loose:?}"); + } + #[test] fn list_view_names_the_styles_and_deletes_the_selected_ones_after_asking() { let mut app = app(); diff --git a/docs/mcp.md b/docs/mcp.md index 1232ffb131..274db05a34 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6817,7 +6817,8 @@ DCS Transparency Interactions, off by default) adds `dcsTransparency: true` to t and the panel shows a warning mark on them. VectorCraft's rule for it: the image, or a group or layer holding it, has an opacity below 100%, a blend mode other than Normal, an opacity mask or an effect, or its bounds overlap the bounds of another visible object that has one of those or a gradient with a transparent stop (in a fill, a stroke or -a character's fill or stroke); a DCS image with transparency of its own counts as such an object. The check looks at +a character's fill or stroke); a DCS image with transparency of its own counts as such an object. Bounds are cut to the +bounds of the clipping paths of the clip groups and clipping layers holding an object. The check looks at every object in the layers each time the list is read. A `dcsTransparency` that is not true or false is an error. `links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose file is missing stays linked: relink it first); `links.unembed {id, path}` writes an embedded image to a file diff --git a/docs/roadmap.md b/docs/roadmap.md index c45a6555e3..6f631fe88b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -52,6 +52,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/swatches/tests.rs`). Scores and estimates unchanged. | @@ -150,7 +151,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M0 | Skeleton + vertical slice | ✅ done | — | | 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 copies transformed character dimensions (#6404) and 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; Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); | Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); 4–8 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and 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; Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); | 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); 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; PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); | 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7904896324..c0b03012de 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-13 · **Change:** minor (Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -53,6 +53,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/swatches/tests.rs`). Scores and estimates unchanged. | @@ -499,7 +500,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | | Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (nine generated in code including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | -| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | +| Appearance, transparency, graphic styles, masks | 5 | 98% | Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | @@ -1350,6 +1351,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A blend in a `.ai` or EPS opened from its editing data keeps a Specified Distance spacing with its distance ([Blend Options](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-options-overview.html)) where VectorCraft lays out as many steps between each pair of keys as the file draws, in the same places and colors. A Smooth Color blend opens live only where VectorCraft's own Smooth Color step count equals the drawn steps, and VectorCraft often counts a different number. A blend that VectorCraft doesn't lay out as drawn keeps its drawn steps with a note (`import::ai::tests::a_blend_keeps_its_spacing`, `a_blend_of_three_keys_keeps_its_distance_between_each_pair`, `a_blend_whose_spacing_doesnt_lay_out_its_steps_keeps_them`). - Create Strokes uses exact squared Euclidean distances and Guo–Hall thinning in bounded per-blob crops. Maximal-disc pruning, split-crossing merges and degree-two joins simplify its skeleton graph. The whole-blob width rule and ink fitting remain; 46 code-drawn inputs measure ends and junctions in [`image-trace-quality.md`](image-trace-quality.md#centre-lines). Junction degrees measure 44/45 correct in each input variant; remaining spurs are recorded. This is a measured skeleton improvement; the Image Trace score, missing per-feature selection and width controls, and estimates remain unchanged. + - Create Strokes decides stroke or fill per feature, as the [help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/image-trace-panel-options.html) describes the Strokes width: the widest feature of the image that can become a stroke. A shape wider than Stroke somewhere keeps as its area every disc wider than Stroke that fits in it, and each piece outside those discs that reaches out of them at least one and a half times its own width (and 3 px) is traced as a stroked center line, its end at the area carried to the cut so the line's cap closes the join. Corners and edge slivers stay in the area. Which pieces count as features is VectorCraft's own rule. Each line still has one width; without Fills, the area is a 1 px outline as before. Tests: `thin_features_of_a_wide_shape_are_strokes_and_its_body_a_fill`, `wide_shapes_without_thin_features_stay_one_fill` and `distances_to_seeds_equal_brute_force` in `vectorcraft-trace`. The Image Trace score and estimates are unchanged. Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`. With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b2dd7d23a1..5391e3dc5b 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-13 · **Change:** minor (library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: 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). > @@ -51,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638). Scores unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | @@ -167,6 +168,7 @@ tools, never checked side by side) isn't captured row by row. | Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain | | Icon-strip flyout resizing (#5713) | done | Canvas-side edge and bottom; user size persists after reopening and workspace restore; Glyphs and Artboards pointer/cursor regression | | Icon-strip flyout resizing from the top (storytold/craft-libs#1094) | done | Top edge (a 4 pt band over the tab strip) and the canvas-side top corner; the bottom and the icon-column edge stay; the moved top persists after reopening and workspace restore; `an_icon_flyout_grows_upward_from_its_top_edge_and_free_corner` | +| Graphic Styles list fills a taller panel (#2992) | done | Docked, floating or in a resized flyout, the list takes the height above the panel's footer, as the Artboards list does; outside a sized panel it stays 220 pt; `the_list_fills_a_tall_panel_above_its_footer` | | Library panels' lists fill a taller panel (#2992) | done | Swatch (Color Books among them), brush, symbol and graphic style library panels: docked, floating or in a resized flyout, the list takes the height above the panel's footer; outside a sized panel it stays 220 pt (280 pt for large thumbnails); `the_list_fills_a_tall_panel_above_its_footer` in `panels/library_panel/tests.rs` | | Swatches list fills a taller panel (#1638) | done | Docked, floating or in a resized flyout, the swatch list takes the height above the panel's footer, as the Artboards list does; outside a sized panel it stays 150 pt (200 pt for large thumbnails); `the_list_fills_a_tall_panel_above_its_footer` in `panels/swatches/tests.rs` | | Layers panel lists a blend's spine (#4820) | done | an open blend shows a `` row below its key objects, where Release leaves the spine; a click selects the blend (Shift/Cmd toggles it) so Direct Selection edits the spine; a locked blend's row selects nothing; no row while searching or filtering (`a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend`) |