diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index e7e74972e6..9934ea026e 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -85,7 +85,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, outer_effects, outline_art, - outline_text, reshape, + outline_text, 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 5e86dafe96..70e5994cb5 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -508,6 +508,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 @@ -520,7 +534,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. @@ -528,10 +541,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 79b835398c..31bcad4671 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -616,6 +616,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| { @@ -660,14 +675,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()); } @@ -676,13 +686,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))); @@ -707,10 +711,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 a97d414500..3788185ae6 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -775,6 +775,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 af3a1272dc..cfb1758f06 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -685,6 +685,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). - **Composite fonts only another document defines:** a document draws, measures and exports its type only in the composite fonts it defines itself. Type naming a composite font that only another open document, or the document a placed document sits in, defines is set as in a missing font, also when the document defines no composite font: in a window, a thumbnail or an export of a document in the back, in a file being opened and in a placed document. A temporary document drawn while a document draws keeps that document's composite fonts. Evidence: `tests::composite::a_lent_document_does_not_use_another_documents_composite_font` and `a_document_without_composite_fonts_uses_none_and_a_preview_is_unchanged` (`crates/text`), `tests::fontchange::a_placed_document_draws_its_own_definition_of_a_name_the_page_defines` and `a_document_draws_type_in_another_documents_composite_font_as_missing` (`crates/render`), `a_document_replaced_in_the_back_measures_a_font_it_lacks_as_missing` (`crates/engine`). 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).