From 77dc581b1c2cfb0a737fb79fb18efe621a27ce0c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 04:05:18 -0800 Subject: [PATCH] Type: Create Outlines keeps a run's gradient across the whole text Create Outlines copied each character run's fills and strokes onto every glyph, ruby glyph and underline or strikethrough bar as they were, so a gradient with no vector of its own refitted to each piece's own box, while the canvas fits it across the whole layout. A gradient with a vector kept its text-space coordinates in the document, and the characters' stroke kept its text-space weight on scaled type. Each piece is now painted by paint_type_piece, the rule outline_text already used: unplaced gradients are fixed to the layout's bounds, then the type's transform places the piece, its gradients, stroke weights and pattern tiles. Object > Expand outlines type the same way, and object.expand.info measures gradient fills on the same paths. --- crates/effects/src/lib.rs | 2 +- crates/effects/src/reshape.rs | 21 ++++++-- crates/engine/src/cmd/expand.rs | 18 +++---- crates/engine/src/cmd/typecmd.rs | 39 ++++++++------- crates/engine/src/tests/objexpand.rs | 72 ++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 6 files changed, 121 insertions(+), 32 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index c18a31d842..ef7e4ce0e7 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -82,7 +82,7 @@ pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_instance_row_raster, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, - reshape, + paint_type_piece, reshape, }; pub use revolve::{ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 83ff68431f..c601ae0d5d 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -475,6 +475,20 @@ fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec, kurbo::Bez (runs, all, layout.bounds, colours) } +/// Paint `piece`, a piece of type's outline in text space (glyphs, ruby glyphs or an underline or +/// strikethrough bar), with `style`, the fills and strokes of its run, and place it in the document +/// by `xf`, the type's transform, as the renderer paints the run: its unplaced gradients are fixed +/// to `bounds`, the layout's bounds in text space, so every piece of the type shows the one +/// gradient fitted across the whole layout, and the transform also scales its stroke weights and +/// dashes and transforms its pattern tiles. Placed gradients keep their place in text space and +/// move with the type. +pub fn paint_type_piece(piece: &mut Node, style: &vectorcraft_doc::CharStyle, bounds: Rect, xf: Affine) { + let mut ap = style.appearance(); + ap.pin_gradients(bounds); + piece.appearance = ap.into(); + piece.transform(xf, vectorcraft_doc::Scaling { strokes: true, patterns: true, ..Default::default() }); +} + /// Type as glyph outlines in document space: one path per run with the run's fill and stroke, /// over its colour glyphs (images, art), and (when the object has its own fills/strokes) the whole /// outline with the object's items below the characters under them and the others over them, as @@ -487,7 +501,6 @@ pub fn outline_text(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None }; let (runs, all, bounds, colours) = laid_out(t); let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf); - let scaling = vectorcraft_doc::Scaling { strokes: true, patterns: true, ..Default::default() }; let mut children: Vec> = vec![]; for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() { // Colour glyphs paint themselves, under their run's fill, as the renderer paints them. @@ -495,10 +508,8 @@ pub fn outline_text(n: &Node) -> Option { if bp.elements().is_empty() { continue; } - let mut ap = run.style.appearance(); - ap.pin_gradients(bounds); - let mut piece = Node::path(n.id, PathData::from_bezpath(bp), ap); - piece.transform(t.xf, scaling); + let mut piece = Node::path(n.id, PathData::from_bezpath(bp), Appearance::default()); + paint_type_piece(&mut piece, &run.style, bounds, t.xf); children.push(Arc::new(piece)); } if !n.appearance.items.is_empty() && !all.elements().is_empty() { diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 02dbad5d23..43975fe4e4 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -265,25 +265,27 @@ fn gradient_expands(d: &Document, l: &Node) -> bool { /// 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. +/// bars, each painted as its run with its unplaced gradients fitted to the layout's bounds, has one +/// ([`gradient_expands`] on that path). 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 first = t.first_style(); 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(_)) { + let style = t.runs.get(run).map_or(&first, |r| &r.style); + if !matches!(style.fill, Paint::Gradient(_)) { return false; } - let path = PathData::from_bezpath(outline).transformed(t.xf); + let path = PathData::from_bezpath(outline); 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 mut piece = Node::path(NodeId(u64::MAX), path, Appearance::default()); + vectorcraft_render::effects::paint_type_piece(&mut piece, style, lay.bounds, t.xf); + gradient_expands(d, &piece) }; 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)) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index b3fab4a2bc..3d9298890c 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -594,6 +594,21 @@ fn scale_text_attributes(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "changed": changed })) } +/// A path (or compound path) of `outline`, a piece of type `t`'s outline in text space, painted as +/// run `run` and placed in the document as the canvas paints it ([`paint_type_piece`]: unplaced +/// gradients fitted to `bounds`, the layout's bounds, stroke weights scaled by the type's +/// transform); `None` when the piece has no path. +fn type_piece(d: &mut Document, t: &TextObject, bounds: kurbo::Rect, run: usize, outline: &kurbo::BezPath) -> Option { + let path = PathData::from_bezpath(outline); + if path.is_empty() { + return None; + } + let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); + let mut node = shape_node(d, path, None); + vectorcraft_render::effects::paint_type_piece(&mut node, &st, bounds, t.xf); + Some(node) +} + fn create_outlines(s: &mut Session, _: &Value) -> Result { let roots = selected_roots(s)?; let ids = s.edit("Create Outlines", |d, sel| { @@ -636,14 +651,9 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { children.push(Arc::new(d.reid(&art))); break 'glyph; } - let path = PathData::from_bezpath(&g.outline).transformed(t.xf); - if path.is_empty() { - break 'glyph; + if let Some(node) = type_piece(d, t, lay.bounds, g.run, &g.outline) { + children.push(Arc::new(node)); } - let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); - let mut node = shape_node(d, path, None); - node.appearance = st.appearance().into(); - children.push(Arc::new(node)); } run_end.insert(g.run, children.len()); } @@ -652,13 +662,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { // nothing there either). let mut ruby: Vec<(usize, Arc)> = vec![]; for g in &lay.ruby { - let path = PathData::from_bezpath(&g.outline).transformed(t.xf); - if path.is_empty() { - continue; - } - let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); - let mut node = shape_node(d, path, None); - node.appearance = st.appearance().into(); + let Some(node) = type_piece(d, t, lay.bounds, g.run, &g.outline) else { continue }; // After the glyphs of its run and of the runs before it. let at = run_end.range(..=g.run).map(|(_, end)| *end).max().unwrap_or(0); ruby.push((at, Arc::new(node))); @@ -683,10 +687,9 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { // 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)); + if let Some(node) = type_piece(d, t, lay.bounds, run, &bar) { + 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 diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index c8b51033fe..91b9c356c4 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -754,6 +754,78 @@ fn create_outlines_draws_the_type_objects_own_paint() { assert!(differing(&before, &render(&session()), 24) > 1000, "the type paints something"); } +/// Selected type "HIH" at 40 pt with ruby, underlined and struck through, its characters filled +/// with `fill` (a `paint.setFill` param) and stroked 2 pt wide, then scaled to 150% and rotated 20 +/// degrees (the stroke keeps its 2 pt, which is 4/3 pt in text space). +fn transformed_gradient_type(s: &mut Session, fill: Value) -> NodeId { + let t = run(s, "text.create", json!({"x": 30, "y": 110, "text": "HIH", "size": 40}))["id"].as_u64().unwrap(); + run(s, "select.set", json!({"ids": [t]})); + run(s, "type.ruby", json!({"text": "ab"})); + run(s, "text.setFormat", json!({"ids": [t], "underline": true, "strikethrough": true})); + run(s, "paint.setFill", fill); + run(s, "paint.setStroke", json!({"color": "#008000"})); + run(s, "stroke.set", json!({"weight": 2})); + run(s, "object.scale", json!({"ids": [t], "sx": 150, "sy": 150})); + run(s, "object.rotate", json!({"ids": [t], "angle": 20})); + NodeId(t) +} + +/// Create Outlines draws what the type drew: a gradient on the characters that has no vector of +/// its own keeps one gradient across every glyph, ruby glyph and bar, fitted to the layout's box, +/// and one with a vector keeps it where it was, both with the type's scale and rotation. The +/// characters' stroke keeps the weight it was drawn at. +#[test] +fn create_outlines_keeps_a_character_gradient_across_the_type() { + let placed = json!({"gradient": {"kind": "linear", "start": [40, 100], "end": [110, 100], + "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}); + for fill in [red_blue(), placed] { + let mut s = session(); + let t = transformed_gradient_type(&mut s, fill.clone()); + let NodeKind::Text(text) = &node(&s, t).kind else { panic!("not type") }; + assert!(!text.ruby.is_empty() && text.runs.iter().all(|r| r.style.underline && r.style.strikethrough), "{fill}"); + // The characters' stroke is kept in text space and drawn scaled by the type's transform. + let weight = text.runs[0].style.stroke_layer().width; + let drawn = weight * vectorcraft_doc::Scaling::factor(text.xf).unwrap_or(1.0); + assert!((drawn - weight).abs() > 0.1, "the type's transform scales: {weight} {drawn}"); + let before = render(&s); + let g = run(&mut s, "type.createOutlines", json!({}))["ids"][0].as_u64().unwrap(); + let after = render(&s); + let n = differing(&before, &after, 24); + assert!(n < 60, "{fill}: {n} pixels differ"); + assert!(differing(&before, &render(&session()), 24) > 1000, "the type paints something"); + // Every glyph, ruby glyph and bar takes the same gradient, placed, and the stroke at the + // weight it was drawn at. + let mut gradients = vec![]; + let mut widths = vec![]; + node(&s, NodeId(g)).walk(&mut |c: &Node| { + if let Paint::Gradient(gr) = c.appearance.fill_paint() { + gradients.push(gr.clone()); + } + widths.extend(c.appearance.stroke().map(|st| st.width)); + }); + assert!(gradients.len() >= 6, "{fill}: three glyphs, ruby and two bars: {}", gradients.len()); + assert!(gradients.iter().all(|gr| gr.is_placed() && *gr == gradients[0]), "{fill}: {gradients:?}"); + assert!(widths.len() == gradients.len() && widths.iter().all(|w| (w - drawn).abs() < 1e-9), "{fill}: {drawn} {widths:?}"); + } +} + +/// Expand outlines the type before its fills expand, so a gradient on its characters that has no +/// vector of its own expands across the whole type, as the canvas draws it, and the query reports +/// it. +#[test] +fn expand_keeps_a_character_gradient_across_the_type() { + let mut s = session(); + transformed_gradient_type(&mut s, red_blue()); + let info = run(&mut s, "object.expand.info", json!({})); + assert_eq!((info["gradient"].clone(), info["fill"].clone(), info["object"].clone()), (json!(true), json!(true), json!(true)), "{info}"); + let before = render(&s); + run(&mut s, "object.expand", json!({})); + let after = render(&s); + assert_eq!(gradients_and_type(&s), (0, 0), "outlined and expanded"); + let d = mean_diff(&before, &after); + assert!(d < 0.5, "{d}"); +} + /// `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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..bc91cca665 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -678,6 +678,7 @@ since 2026-10-01. - **COLR colour fonts:** v0 and v1 glyphs draw as vector layers (solid colours, linear and radial gradients, including ones that repeat or reflect, 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, Porter-Duff compositing and radial gradients that repeat or reflect and start at a circle 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). +- **Create Outlines keeps the characters' gradients across the type:** Type › Create Outlines and Object › Expand give every glyph, ruby glyph and underline or strikethrough bar the gradient its characters drew with: a gradient with no vector of its own stays fitted to the whole layout rather than to each glyph, and one with a vector keeps its place, both moved, scaled and rotated with the type; the characters' stroke keeps the weight it was drawn at on scaled type. `object.expand.info` measures gradient fills on the same paths. Outlined characters keep their stroke and fill (the help page, ); the gradient fitted across the whole layout is VectorCraft's own rule. Measured by `tests::objexpand::create_outlines_keeps_a_character_gradient_across_the_type` and `tests::objexpand::expand_keeps_a_character_gradient_across_the_type`. 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.