diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index d4a6c60802..02dbad5d23 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -25,7 +25,7 @@ use super::dimension::EndedLink; use super::edit::selected_roots; use super::menucmds::{run_raw, squash}; use super::object::as_clipping_path; -use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency}; +use super::pathops::{assemble, node_path, outlinable, outline_strokes, replace_leaves, styled_copy, with_transparency}; use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_selection, number_or, switch_or, text_param}; const C: &str = "object.expand"; @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines (its own fills and strokes on paths of its whole outline, which Fill and Stroke expand), live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, expand ), @@ -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 (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)", + "{} 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, or of the whole type for the type object's own fills), 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)?; @@ -189,21 +189,37 @@ impl Expandable { e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_)); e.repeats |= matches!(c.kind, NodeKind::Repeat(_)); e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. }); + // Type's outlines take its characters' stroke and fill, and a path of its whole outline + // takes the type object's own fills and strokes (see `type.createOutlines`). + // Any stroke Outline Stroke outlines, not only the topmost one. + let own_stroke = c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))); + let own_pattern = c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()); + let own_gradient = c.appearance.items.iter().any(|i| gradient_fill(i).is_some()); + // Type without an outline (empty, or only spaces) gets no path for its own fills and strokes. + let whole = match &c.kind { + NodeKind::Text(t) if own_stroke || own_pattern || own_gradient => text_whole_outline(t), + _ => None, + }; e.stroke |= match &c.kind { - // Type's outlines take its characters' stroke (see `type.createOutlines`). - NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()), - _ => shape && !c.appearance.stroke_paint().is_none(), + NodeKind::Text(t) => (own_stroke && whole.is_some()) || t.runs.iter().any(|r| r.style.has_stroke()), + _ => shape && own_stroke, }; // Once one is found, the next path is not measured. e.gradient = e.gradient || match &c.kind { - NodeKind::Text(t) => text_gradient_expands(d, t), + NodeKind::Text(t) => { + text_gradient_expands(d, t) + || (own_gradient && whole.as_ref().is_some_and(|(path, tb)| text_object_gradient_expands(d, c, path, *tb))) + } _ => shape && gradient_expands(d, c), }; e.fill |= e.gradient; e.fill |= match &c.kind { - NodeKind::Text(t) => t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())), - _ => shape && c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()), + NodeKind::Text(t) => { + (own_pattern && whole.is_some()) + || t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())) + } + _ => shape && own_pattern, }; }); } @@ -275,6 +291,33 @@ fn text_gradient_expands(d: &Document, t: &TextObject) -> bool { || vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t).iter().any(|(run, bar)| expands(*run, bar)) } +/// The path of type `t`'s whole outline in the document (glyphs, ruby glyphs and underline and +/// strikethrough bars), which `type.createOutlines` paints with the type object's own fills and +/// strokes, and the layout's bounds there; `None` when the type has no outline. +fn text_whole_outline(t: &TextObject) -> Option<(PathData, Rect)> { + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, t); + let mut all = BezPath::new(); + for g in lay.glyphs.iter().chain(&lay.ruby) { + all.extend(g.outline.iter()); + } + for (_, bar) in vectorcraft_text::decorations(&lay, db, t) { + all.extend(bar.iter()); + } + let path = PathData::from_bezpath(&all).transformed(t.xf); + (!path.is_empty()).then(|| (path, t.xf.transform_rect_bbox(lay.bounds))) +} + +/// Whether type node `n` has a gradient fill of its own (not its characters') that Fill expands +/// once Object has outlined it: [`gradient_expands`] on `path`, the type's whole outline +/// ([`text_whole_outline`]), with its unplaced gradients fixed to `tb`, the layout's bounds in the +/// document. Leaves the document as it is. +fn text_object_gradient_expands(d: &Document, n: &Node, path: &PathData, tb: Rect) -> bool { + let mut appearance = Appearance { items: n.appearance.items.clone(), ..Default::default() }; + appearance.pin_gradients(tb); + gradient_expands(d, &Node::path(NodeId(u64::MAX), path.clone(), appearance)) +} + /// [`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/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 0085c2d9d6..878b3743cf 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -962,7 +962,7 @@ pub(crate) fn ordered_map( // ---------- Outline Stroke ---------- /// Can Outline Stroke turn `st` into filled art? -fn outlinable(st: &StrokeLayer) -> bool { +pub(crate) fn outlinable(st: &StrokeLayer) -> bool { st.visible && !st.paint.is_none() && st.width > 0.0 } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 94ed16ebd8..6b14c98916 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -4,7 +4,9 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject}; +use vectorcraft_doc::{ + Appearance, AppearanceItem, AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject, +}; use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; @@ -39,7 +41,7 @@ pub fn specs() -> Vec { "Create Outlines", ["Type"], Some("Cmd+Shift+O"), - "{} convert the selected text to groups of compound paths (one per glyph); a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}", + "{} convert the selected text to groups of compound paths (one per glyph); the type object's own fills and strokes (not its characters') go on paths of its whole outline, below or above the glyphs as its Characters row places them; a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}", has_selection, create_outlines ), @@ -672,13 +674,39 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { } placed.extend(ruby.map(|(_, r)| r)); let mut children = placed; + // The whole outline the object's own fills and strokes paint: every glyph, ruby glyph + // and bar, as the canvas paints them. + let mut all = kurbo::BezPath::new(); + for g in lay.glyphs.iter().chain(&lay.ruby) { + all.extend(g.outline.iter()); + } // Underline and strikethrough bars become paths of their run's paint too (#847). for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t) { + all.extend(bar.iter()); let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); let mut node = shape_node(d, PathData::from_bezpath(&bar).transformed(t.xf), None); node.appearance = st.appearance().into(); children.push(Arc::new(node)); } + // The object's own fills and strokes (not its characters') paint the whole outline: + // those below the Characters row under the characters, the others over them, with + // their unplaced gradients fixed to the layout's bounds in the document. + let all = PathData::from_bezpath(&all).transformed(t.xf); + if !n.appearance.items.is_empty() && !all.is_empty() { + let (below, above) = n.appearance.split_contents(); + let tb = t.xf.transform_rect_bbox(lay.bounds); + let mut whole = |items: &[AppearanceItem]| { + let mut ap = Appearance { items: items.to_vec(), ..Default::default() }; + ap.pin_gradients(tb); + Arc::new(Node::path(d.alloc_id(), all.clone(), ap)) + }; + if !below.is_empty() { + children.insert(0, whole(below)); + } + if !above.is_empty() { + children.push(whole(above)); + } + } let (par, idx, _) = d.position(tid).ok_or(EngineError::NoNode(tid))?; d.remove(tid)?; if children.is_empty() { diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 32c0cb143f..c8b51033fe 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -712,6 +712,159 @@ fn type_in_symbol_art_reports_a_gradient_only_when_it_expands() { } } +/// Selected type "Hi" at 64 pt whose characters have no paint, with fills and strokes of its own: +/// each of `items` is an `appearance.setItem` edit of a new object-level stroke when it has a +/// `weight`, else of a new object-level fill; the first `below` of them paint below the characters. +fn type_with_own_paint(s: &mut Session, items: &[Value], below: usize) -> NodeId { + let t = run(s, "text.create", json!({"x": 25, "y": 110, "text": "Hi", "size": 64}))["id"].as_u64().unwrap(); + run(s, "select.set", json!({"ids": [t]})); + run(s, "paint.setFill", json!({"none": true})); + run(s, "paint.setStroke", json!({"none": true})); + for (i, item) in items.iter().enumerate() { + let add = if item.get("weight").is_some() { "appearance.addStroke" } else { "appearance.addFill" }; + run(s, add, json!({"target": "object"})); + let mut p = item.clone(); + p["index"] = json!(i); + run(s, "appearance.setItem", p); + } + run(s, "appearance.moveItem", json!({"from": "contents", "to": below})); + NodeId(t) +} + +fn blue_stroke() -> Value { + json!({"color": "#0000ff", "weight": 5}) +} + +fn red_blue() -> Value { + json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}) +} + +/// Create Outlines draws what the type drew: a gradient fill of the type object's own, below its +/// characters, keeps one gradient across both glyphs over the layout's box, and its stroke stays +/// over them. +#[test] +fn create_outlines_draws_the_type_objects_own_paint() { + let mut s = session(); + type_with_own_paint(&mut s, &[red_blue(), blue_stroke()], 1); + let before = render(&s); + run(&mut s, "type.createOutlines", json!({})); + let after = render(&s); + let n = differing(&before, &after, 24); + assert!(n < 40, "{n} pixels differ"); + assert!(differing(&before, &render(&session()), 24) > 1000, "the type paints something"); +} + +/// `object.expand.info` reports the type object's own stroke, pattern fill and gradient fill, which +/// Object turns into paths of its whole outline: a gradient only when that outline's box needs no +/// more runs than are written. +#[test] +fn type_objects_own_stroke_pattern_and_gradient_are_reported() { + let mut s = session(); + type_with_own_paint(&mut s, &[blue_stroke()], 0); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!((info["object"].clone(), info["stroke"].clone(), info["fill"].clone()), (json!(true), json!(true), json!(false)), "{info}"); + + let mut s = session(); + pattern_rect(&mut s, Rect::new(150.0, 150.0, 190.0, 190.0), false); + let t = type_with_own_paint(&mut s, &[json!({"swatch": "Dots"})], 0); + run(&mut s, "select.set", json!({"ids": [t.0]})); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!((info["fill"].clone(), info["gradient"].clone(), info["stroke"].clone()), (json!(true), json!(false), json!(false)), "{info}"); + + // 0.4 pt runs across about 80 pt: under the limit; 0.02 pt runs: far over it. + for (len, expands) in [(40.0, true), (2.0, false)] { + let mut s = session(); + let gradient = json!({"gradient": {"kind": "linear", "spread": "repeat", "repeats": 100, "start": [25, 100], "end": [25.0 + len, 100], + "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}}); + type_with_own_paint(&mut s, &[gradient], 0); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!((info["gradient"].clone(), info["fill"].clone()), (json!(expands), json!(expands)), "{len}: {info}"); + run(&mut s, "object.expand", json!({})); + let (gradients, texts) = gradients_and_type(&s); + assert_eq!(texts, 0, "{len}: outlined"); + assert_eq!(gradients == 0, expands, "{len}: {gradients} gradient fills left"); + } +} + +/// Type with only spaces has no outline for its own stroke or pattern fill to go on, so +/// `object.expand.info` reports neither. +#[test] +fn type_without_an_outline_reports_no_own_stroke_or_fill() { + let mut s = session(); + pattern_rect(&mut s, Rect::new(150.0, 150.0, 190.0, 190.0), false); + let t = run(&mut s, "text.create", json!({"x": 25, "y": 110, "text": " ", "size": 64}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids": [t]})); + run(&mut s, "paint.setFill", json!({"none": true})); + run(&mut s, "paint.setStroke", json!({"none": true})); + for (i, item) in [json!({"swatch": "Dots"}), blue_stroke()].into_iter().enumerate() { + let add = if item.get("weight").is_some() { "appearance.addStroke" } else { "appearance.addFill" }; + run(&mut s, add, json!({"target": "object"})); + let mut p = item; + p["index"] = json!(i); + run(&mut s, "appearance.setItem", p); + } + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!( + (info["object"].clone(), info["stroke"].clone(), info["fill"].clone(), info["gradient"].clone()), + (json!(true), json!(false), json!(false), json!(false)), + "{info}" + ); +} + +/// How many objects in the document have a visible stroke with paint and weight. +fn painted_strokes(s: &Session) -> usize { + let mut n = 0; + s.doc().unwrap().doc.walk(|c| { + n += usize::from( + c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.visible && !st.paint.is_none() && st.width > 0.0)), + ); + }); + n +} + +/// A visible stroke under a topmost stroke with no paint still has something for Stroke to +/// outline, on type's own appearance and on a path: `object.expand.info` reports it, and Expand +/// outlines it. +#[test] +fn a_stroke_under_an_unpainted_stroke_is_reported_and_outlined() { + let mut s = session(); + type_with_own_paint(&mut s, &[blue_stroke(), json!({"none": true, "weight": 1})], 0); + let mut r = session(); + run(&mut r, "shape.rectangle", json!({"x": 40, "y": 40, "width": 100, "height": 80})); + run(&mut r, "paint.setStroke", json!({"color": "#0000ff"})); + run(&mut r, "stroke.set", json!({"weight": 4})); + run(&mut r, "appearance.addStroke", json!({"target": "object"})); + let top = node(&r, selected(&r).id).appearance.items.len() - 1; + run(&mut r, "appearance.setItem", json!({"index": top, "none": true})); + for (what, s) in [("type", &mut s), ("path", &mut r)] { + let info = run(s, "object.expand.info", json!({})); + assert_eq!(info["stroke"], json!(true), "{what}: {info}"); + run(s, "object.expand", json!({})); + assert_eq!(painted_strokes(s), 0, "{what}: the stroke is outlined"); + } +} + +/// Expand keeps the type object's own stroke and gradient fill through Object: Stroke outlines the +/// stroke and Fill expands the gradient, and with those options off they stay live on the path of +/// the whole outline. The picture stays the same either way. +#[test] +fn expand_keeps_the_type_objects_own_paint() { + for (opts, live) in [(json!({}), false), (json!({"fill": false, "stroke": false}), true)] { + let mut s = session(); + type_with_own_paint(&mut s, &[red_blue(), blue_stroke()], 1); + let before = render(&s); + run(&mut s, "object.expand", opts.clone()); + let after = render(&s); + assert!(mean_diff(&before, &after) < 2.0, "{opts}: {}", mean_diff(&before, &after)); + let (gradients, texts) = gradients_and_type(&s); + assert_eq!(texts, 0, "{opts}: outlined"); + assert_eq!(gradients == 1, live, "{opts}: {gradients} gradient fills"); + let mut strokes = 0; + s.doc().unwrap().doc.walk(|n| strokes += usize::from(!n.appearance.stroke_paint().is_none())); + assert_eq!(strokes == 1, live, "{opts}: {strokes} strokes"); + } +} + #[test] fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { for gradient in [ diff --git a/crates/engine/src/tests/pathops.rs b/crates/engine/src/tests/pathops.rs index b587740744..0564589d2d 100644 --- a/crates/engine/src/tests/pathops.rs +++ b/crates/engine/src/tests/pathops.rs @@ -1043,6 +1043,51 @@ fn create_outlines_matches_text_bounds() { assert!(s.doc().unwrap().doc.node(t).is_some()); } +/// The type object's own fills and strokes (not its characters') become paths of its whole outline, +/// with ids of their own: those below the Characters row under the glyph paths, the others over +/// them, in their order. The group keeps no appearance of its own, and Undo brings the type back. +#[test] +fn create_outlines_keeps_the_objects_own_fills_and_strokes() { + let mut s = session(); + let t = text(&mut s, "Hi", 64.0); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + for (cmd, p) in [ + ("appearance.addFill", json!({"target": "object"})), + ("appearance.setItem", json!({"index": 0, "color": "#ff0000"})), + ("appearance.addStroke", json!({"target": "object"})), + ("appearance.setItem", json!({"index": 1, "color": "#0000ff", "weight": 5})), + ("appearance.addFill", json!({"target": "object"})), + ("appearance.setItem", json!({"index": 2, "color": "#00ff00", "opacity": 50})), + // The red fill paints below the characters; the stroke and the green fill over them. + ("appearance.moveItem", json!({"from": "contents", "to": 1})), + ] { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); + } + let own = node(&s, t).appearance.items.clone(); + assert_eq!(own.len(), 3); + let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap()); + let g = node(&s, r[0]); + assert!(g.appearance.items.is_empty(), "the group paints nothing of its own"); + let ch = g.children().unwrap(); + assert_eq!(ch.len(), 4, "a path below, the two glyphs, a path above"); + let (first, last) = (&ch[0], &ch[3]); + assert_eq!(first.appearance.items, own[..1], "the item below the characters"); + assert_eq!(last.appearance.items, own[1..], "the items over them, in their order"); + for c in [first, last] { + assert!(matches!(c.kind, NodeKind::Path { .. }), "{:?}", c.kind); + assert!(c.id != t && c.id != g.id, "a fresh id"); + } + assert!(first.id != last.id); + for c in &ch[1..3] { + assert_eq!(c.appearance.fill_paint(), Paint::solid(Color::BLACK), "the characters keep their own paint"); + } + // The whole-outline paths cover both glyphs. + let glyphs = ch[1].geometric_bounds().unwrap().union(ch[2].geometric_bounds().unwrap()); + assert!(close(first.geometric_bounds().unwrap(), glyphs, 1e-6), "{:?} vs {glyphs:?}", first.geometric_bounds()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, t).appearance.items, own, "one undo step brings the type back"); +} + /// Colour bitmap glyphs (emoji) become embedded images where they sat; glyphs showing one image /// share its pixels. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 912ac32562..c82f17688b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4911,7 +4911,9 @@ stay live. `object.expand.info` answers which options have something to expand i (`{object, fill, stroke, gradient, appearance, symbolArt?}`; `gradient` says whether Fill has a gradient fill, which Expand 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, +ruby character, underline or strikethrough, and for a fill of the type object itself the box of its whole outline; +`stroke` and `fill` count the type object's own strokes and pattern fills as well as its characters' (its own +only when the type has an outline, which type of only spaces does not); `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 @@ -8328,6 +8330,14 @@ indent and bullet or number again. {"name":"run_command","arguments":{"command":"text.thread.add","params":{"from":42,"at":[300,120]}}} ``` +`type.createOutlines` (Type › Create Outlines) makes a group with a path per glyph, ruby glyph and underline or +strikethrough bar, each painted as its characters are. The type object's own fills and strokes (the items around the +Appearance panel's Characters row, not the characters' formatting) go on paths of the type's whole outline: the items +below the Characters row on one path under the glyph paths, the others on one path over them, in their order and with +their opacity, blend mode, effects and stroke settings. Their gradients that are not placed are fixed to the type's +layout box, as the canvas paints them, so one gradient spans every glyph. The group itself has no fills or strokes. +`object.expand` outlines type with this command, so Fill and Stroke expand those paths' gradients, patterns and strokes. + `type.createOutlines` (Type › Create Outlines) and `effect.expandAppearance` (when it outlines type, as Expand Appearance describes) take an outlined frame of threaded text out of its thread with the text it shows, and the other frames keep the text they hold. When the thread's last frame is outlined, the text it has no room for, which its diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 26681e5b7f..d5b3d6d4e4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -667,6 +667,7 @@ since 2026-10-01. - **Colour emoji with bitmap glyphs:** `sbix` (Apple Color Emoji) and `CBDT` (Noto Color Emoji) glyphs draw as their largest strike's PNG, placed with the type and unpainted by its fill; PDF writes them in Type 3 glyphs or as images, SVG as images with outlined text, EPS and legacy `.ai` as images, and Create Outlines makes embedded images. Synthetic test fonts in `crates/text/src/test_fonts.rs`; tests `crates/text/src/tests/bitmaps.rs`, `crates/render/src/tests/bitmapglyphs.rs`, `crates/svg/tests/it/colour_glyphs.rs`, `pdf::tests::colour_bitmap_glyphs_export_as_images`, the `eps` `colour_bitmap_glyphs_*` tests and `tests_pathops::create_outlines_turns_bitmap_glyphs_into_images`. COLR and OpenType SVG fonts remain. - **COLR colour fonts:** v0 and v1 glyphs draw as vector layers (solid colours, linear and radial gradients, clips, blend modes, the foreground colour from the run's fill) on the canvas, as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as Type 3 text in PDF, and as editable groups with Create Outlines; sweep gradients and Porter-Duff compositing draw the glyph as an image. Measured by `tests_colr`, `render::tests_colrglyphs`, `pdf::tests::colr_glyphs_export_as_vector_art`, `svg/tests/it/colour_glyphs.rs`, `eps` `colr_glyphs_*`, `tests_pathops::create_outlines_turns_colr_glyphs_into_layer_groups`. Scores unchanged (estimated). - **OpenType SVG colour fonts:** each glyph's SVG document is read by the SVG importer (registered by the engine) into vector art in the run's colours, then drawn, exported and outlined as COLR glyphs are; PDF real text writes them as text that draws nothing and draws their art where they sit. Measured by `tests_svg_glyphs`, `svg/tests/it/svg_glyphs.rs`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`. Scores unchanged (estimated). +- **Create Outlines keeps the type object's own appearance:** Type › Create Outlines and Object › Expand put the type object's own fills and strokes (the Appearance panel's items around its Characters row) on paths of the whole outline, below or above the glyph paths as that row places them, with one gradient across every glyph, so the outlines draw what the type drew; `object.expand.info` reports those strokes, pattern fills and gradient fills. Outlined characters keep their stroke and fill (the help page, ); a type object's appearance is separate from its characters' (the help page, ); where the object's items go is VectorCraft's own rule. Measured by `tests::pathops::create_outlines_keeps_the_objects_own_fills_and_strokes`, `tests::objexpand::create_outlines_draws_the_type_objects_own_paint`, `tests::objexpand::type_objects_own_stroke_pattern_and_gradient_are_reported`, `tests::objexpand::type_without_an_outline_reports_no_own_stroke_or_fill`, `tests::objexpand::a_stroke_under_an_unpainted_stroke_is_reported_and_outlined` and `tests::objexpand::expand_keeps_the_type_objects_own_paint`. Scores unchanged (estimated). - **Composite fonts that two documents define differently:** when two open documents each define a composite font (Type › Composite Fonts…) by the same name with different parts, each document draws and exports its type in its own definition: in a tiled or floating window drawn while the front document draws, when it is placed in a document that defines the name differently, and in an export made while another document is in front. Evidence: `tests::composite::nested_loans_of_one_name_use_each_documents_definition` and `threads_lending_one_name_at_once_use_their_own_definitions` (`crates/text`), `tests::fontchange::a_placed_document_draws_its_own_definition_of_a_name_the_page_defines` (`crates/render`). Scores unchanged (estimated). - **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html). - **Character-style numeric bounds:** `charStyle.new`, `charStyle.edit`, and `charStyle.setAttrs` accept size from 0.1 to 1296 pt, explicit leading from 0.1 to 5000 pt, horizontal and vertical scale from 1% to 10,000%, and baseline shift and Tate-chu-yoko offsets from -1296 to 1296 pt. Out-of-range input leaves the style and Undo history unchanged; Auto leading remains available. These limits match the Character panel controls.