diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 0f55386830..352e520433 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -18,8 +18,8 @@ use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, use vectorcraft_doc::appearance::{AppearanceItem, FillLayer}; use vectorcraft_doc::live::GradientMesh; use vectorcraft_doc::pattern::{PatternDef, place_art}; -use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling}; -use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling, TextObject}; +use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, SubPath, Vec2, shapes}; use super::dimension::EndedLink; use super::edit::selected_roots; @@ -56,7 +56,7 @@ pub fn specs() -> Vec { "Expand Options", [], None, - "{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)", + "{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands (in type, on the outline Object makes of a character), which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)", has_selection, |s, _| { let roots = selected_roots(s)?; @@ -197,7 +197,7 @@ impl Expandable { // Once one is found, the next path is not measured. e.gradient = e.gradient || match &c.kind { - NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), + NodeKind::Text(t) => text_gradient_expands(d, t), _ => shape && gradient_expands(d, c), }; e.fill |= e.gradient; @@ -247,6 +247,34 @@ fn gradient_expands(d: &Document, l: &Node) -> bool { any } +/// Whether type `t` has a gradient fill that Fill expands once Object has outlined it: one of the +/// paths `type.createOutlines` makes of its glyphs, ruby glyphs and underline and strikethrough +/// bars, each painted as its run, has one ([`gradient_expands`] on that path's own box). Inline +/// graphics and color glyphs become other art. Leaves the document as it is and stops at the +/// first such path. +fn text_gradient_expands(d: &Document, t: &TextObject) -> bool { + if !t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))) { + return false; + } + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let expands = |run: usize, outline: &BezPath| { + let style = t.runs.get(run).map(|r| &r.style); + if !matches!(style.map_or_else(|| t.first_style().fill, |s| s.fill.clone()), Paint::Gradient(_)) { + return false; + } + let path = PathData::from_bezpath(outline).transformed(t.xf); + if path.is_empty() { + return false; + } + let appearance = style.map_or_else(|| t.first_style().appearance(), |s| s.appearance()); + gradient_expands(d, &Node::path(NodeId(u64::MAX), path, appearance)) + }; + let inline = |gi: usize| lay.inlines.iter().find(|i| i.glyph == gi).is_some_and(|i| t.runs.get(i.run).is_some_and(|r| r.inline.is_some())); + lay.glyphs.iter().enumerate().any(|(gi, g)| !inline(gi) && g.image.is_none() && g.colour.is_none() && expands(g.run, &g.outline)) + || lay.ruby.iter().any(|g| expands(g.run, &g.outline)) + || vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t).iter().any(|(run, bar)| expands(*run, bar)) +} + /// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]). fn piece_gradient_expands(d: &Document, l: &Node) -> bool { if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) { diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 1c931e0de8..b8c4724853 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -629,6 +629,89 @@ fn a_spread_gradient_with_more_runs_than_written_stays_a_gradient() { } } +/// Selected type "Hi" at 48 pt, its characters filled with a gradient that repeats 100 times along +/// a vector `len` pt long. +fn spread_gradient_type(s: &mut Session, len: f64) -> u64 { + let t = run(s, "text.create", json!({"x": 50, "y": 100, "text": "Hi", "size": 48}))["id"].as_u64().unwrap(); + run(s, "select.set", json!({"ids": [t]})); + run( + s, + "paint.setFill", + json!({"gradient": {"kind": "linear", "spread": "repeat", "repeats": 100, "start": [50, 100], "end": [50.0 + len, 100], + "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}}), + ); + t +} + +/// How many objects in the document have a gradient fill, and how many are type. +fn gradients_and_type(s: &Session) -> (usize, usize) { + let (mut gradients, mut texts) = (0, 0); + s.doc().unwrap().doc.walk(|n| { + gradients += usize::from(matches!(n.appearance.fill_paint(), Paint::Gradient(_))); + texts += usize::from(matches!(n.kind, NodeKind::Text(_))); + }); + (gradients, texts) +} + +/// Type is outlined before its fills expand, and each glyph's gradient fits that glyph's own box: +/// `object.expand.info` reports a gradient (and a fill) only when a glyph's box needs no more runs +/// than are written, so Expand turns it into art. Both Expand Gradient To options leave the +/// glyphs whose gradient needs more runs with their gradient, and Object still outlines the type. +/// The query leaves the document and its history as they are. +#[test] +fn type_whose_gradient_needs_more_runs_than_written_reports_no_gradient() { + // 0.2 pt runs (100 repeats over 20 pt) across glyphs about 30 pt wide: under the limit. + // 0.02 pt runs (over 2 pt): far over it. + for (len, expands) in [(20.0, true), (2.0, false)] { + let mut s = session(); + spread_gradient_type(&mut s, len); + let before = (s.doc().unwrap().revision, s.doc().unwrap().history.undo.len(), s.doc().unwrap().doc.layers.clone()); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!((info["gradient"].clone(), info["fill"].clone(), info["object"].clone()), (json!(expands), json!(expands), json!(true)), "{len}"); + assert!( + before == (s.doc().unwrap().revision, s.doc().unwrap().history.undo.len(), s.doc().unwrap().doc.layers.clone()), + "{len}: the query changed the document" + ); + for to in [json!({}), json!({"gradient": "mesh"})] { + let mut s = session(); + spread_gradient_type(&mut s, len); + run(&mut s, "object.expand", to.clone()); + let (gradients, texts) = gradients_and_type(&s); + assert_eq!(texts, 0, "{len} {to}: outlined"); + assert_eq!(gradients == 0, expands, "{len} {to}: {gradients} gradient fills left"); + } + } +} + +/// One glyph whose box needs more runs than are written does not hide another that Expand turns +/// into art: the 200 pt "H" keeps its gradient, the 48 pt "i" expands, and the query reports it. +#[test] +fn type_reports_a_gradient_when_any_glyph_expands() { + let mut s = session(); + let t = spread_gradient_type(&mut s, 20.0); + run(&mut s, "text.setRangeStyle", json!({"id": t, "start": 0, "end": 1, "size": 200})); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!((info["gradient"].clone(), info["fill"].clone()), (json!(true), json!(true))); + run(&mut s, "object.expand", json!({})); + assert_eq!(gradients_and_type(&s), (1, 0), "the large glyph keeps its gradient"); +} + +/// The same rule for type in the art of a symbol instance that Object expands. The symbol's art +/// is the type scaled down (its "i" about 2.5 pt wide), so its glyphs need more runs than are +/// written with 0.002 pt runs (over a 0.2 pt vector). +#[test] +fn type_in_symbol_art_reports_a_gradient_only_when_it_expands() { + for (len, expands) in [(20.0, true), (0.2, false)] { + let mut s = session(); + spread_gradient_type(&mut s, len); + let inst = run(&mut s, "symbol.new", json!({"name": "Hi"}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids": [inst]})); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!(info["gradient"], json!(expands), "{len}"); + assert_eq!((info["symbolArt"]["gradient"].clone(), info["symbolArt"]["fill"].clone()), (json!(expands), json!(expands)), "{len}: {info}"); + } +} + #[test] fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { for gradient in [ diff --git a/docs/mcp.md b/docs/mcp.md index bc4941276e..174e3a6af7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4719,7 +4719,9 @@ and those of the object shape the outline of the clip group, the gradient fits t box, as the canvas paints the fill; the expanded art does not keep them. With `fill: false` gradient and pattern fills stay live. `object.expand.info` answers which options have something to expand in the selection (`{object, fill, stroke, gradient, appearance, symbolArt?}`; `gradient` says whether Fill has a gradient fill, which Expand -Gradient To applies to; `appearance` whether the selection has appearance attributes Expand Appearance expands first, +Gradient To applies to, that it turns into art: it is false when every such fill repeats or reflects in more than 256 +runs across the box it fills, which for type, symbol art included, is the box of each outline Object makes of a character, +ruby character, underline or strikethrough; `appearance` whether the selection has appearance attributes Expand Appearance expands first, for which Object → Expand… is dimmed in the menus; `symbolArt: {fill, stroke, gradient}`, present when the selection holds symbol instances that `object` expands, whether only their art holds it, which Fill and Stroke reach only with `object` on), and `object.mesh.create` on a gradient-filled object colours the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bb7be05517..93b133902d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1771,6 +1771,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Pattern tiles:** the renderer keeps a tile of each pattern for each scale it rasterizes the pattern at, until the tiles fill the tile cache's budget, and then drops the least recently used tiles first. Objects that show one pattern at different scales, such as a pattern scaled to 25% with Transform Pattern Tiles next to the same pattern at 100%, draw from tiles kept between frames. - **Pattern fills in Expand:** Object › Expand with Fill turns a pattern fill into the pattern's tiles in a clip group shaped like the object, with the fill's opacity and blend mode, and the object's other fills and strokes stay above or below it in paint order, in one undo step (`object.expand`). +- **Gradient options for type in Expand:** the Expand dialog enables its gradient options for type, symbol art included, only when the outline of at least one character, ruby character, underline or strikethrough gets a gradient that Fill turns into art. A gradient that repeats or reflects in more than 256 runs across a character's outline, VectorCraft's own limit, stays a gradient after Expand (`object.expand.info`, `crates/engine/src/tests/objexpand.rs`). - **Type with transparency or effects:** type at less than 100% opacity, in a blend mode other than Normal, or with raster effects such as a drop shadow keeps its glyph outlines between frames, as opaque type does, and is laid out again when it is edited or the fonts change. At 2880 × 1800 on 8 threads, a page of 1,000 type objects takes 13 ms a frame instead of 23 ms at 80% opacity, 31 ms instead of 41 ms in Multiply and 16 ms instead of 26 ms with a drop shadow, with the same pixels.