mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
Merge pull request #7797 from dotCooCoo/fix/outlined-strokes-under-a-non-uniform-type
Type: outlined strokes on unevenly scaled type keep their thickness in each direction
This commit is contained in:
@@ -515,18 +515,62 @@ fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::Bez
|
||||
/// 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) {
|
||||
///
|
||||
/// When `xf` is not a similarity (it scales unevenly, shears or flattens), one weight can't give
|
||||
/// the stroke the thickness the canvas paints in each direction: `piece` keeps only the run's fill,
|
||||
/// and the returned path is the area the run's stroke paints around `piece` in text space
|
||||
/// ([`crate::stroke::outline_region`]: dashes, caps, joins and alignment), placed by `xf` and
|
||||
/// filled with the stroke's paint, to paint over `piece`. `None` under a similarity and for a run
|
||||
/// without a stroke.
|
||||
pub fn paint_type_piece(piece: &mut Node, style: &vectorcraft_doc::CharStyle, bounds: Rect, xf: Affine) -> Option<Node> {
|
||||
let scaling = vectorcraft_doc::Scaling { strokes: true, patterns: true, ..Default::default() };
|
||||
let mut ap = style.appearance();
|
||||
ap.pin_gradients(bounds);
|
||||
let stroke = if is_similarity(xf) { None } else { filled_stroke(piece, &mut ap) };
|
||||
piece.appearance = ap.into();
|
||||
piece.transform(xf, vectorcraft_doc::Scaling { strokes: true, patterns: true, ..Default::default() });
|
||||
piece.transform(xf, scaling);
|
||||
stroke.map(|mut s| {
|
||||
s.transform(xf, scaling);
|
||||
s
|
||||
})
|
||||
}
|
||||
|
||||
/// Does `xf` keep angles and scale every direction alike (a move, a rotation, a uniform scale or a
|
||||
/// reflection), without flattening?
|
||||
fn is_similarity(xf: Affine) -> bool {
|
||||
let [a, b, c, d, ..] = xf.as_coeffs();
|
||||
let (x, y) = (a.hypot(b), c.hypot(d));
|
||||
let tol = 1e-9 * x.max(y);
|
||||
x.is_finite() && y.is_finite() && x > 0.0 && y > 0.0 && (x - y).abs() <= tol && (a * c + b * d).abs() <= tol * x.max(y)
|
||||
}
|
||||
|
||||
/// Takes the stroke out of `ap`, the appearance of `piece` (a path or compound path in text space),
|
||||
/// and returns the area it paints there as a path filled with its paint, opacity, blend mode,
|
||||
/// effects and overprint, with `piece`'s id. `None` when `ap` has no stroke or it paints nothing.
|
||||
fn filled_stroke(piece: &Node, ap: &mut Appearance) -> Option<Node> {
|
||||
let i = ap.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(_)))?;
|
||||
let AppearanceItem::Stroke(st) = ap.items.remove(i) else { return None };
|
||||
let path = PathData::from_bezpath(&piece.stroke_path()?);
|
||||
let region = crate::stroke::outline_region(&path, FillRule::NonZero, &st);
|
||||
if region.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let fill = vectorcraft_doc::FillLayer {
|
||||
opacity: st.opacity,
|
||||
blend: st.blend,
|
||||
effects: st.effects.clone(),
|
||||
overprint: st.overprint,
|
||||
..vectorcraft_doc::FillLayer::new(st.paint.stroke_as_fill())
|
||||
};
|
||||
Some(Node::path(piece.id, region, Appearance { items: vec![AppearanceItem::Fill(fill)], ..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
|
||||
/// the renderer paints them. Each run's path is placed by the type's transform, which also scales its stroke
|
||||
/// weights and dashes and transforms its pattern tiles; its unplaced gradients are first fixed to
|
||||
/// weights and dashes and transforms its pattern tiles (a transform that is not a similarity puts
|
||||
/// the run's stroke on a filled path over it, [`paint_type_piece`]); its unplaced gradients are first fixed to
|
||||
/// the layout's bounds in text space. The object's own unplaced gradients are fixed to the
|
||||
/// bounding box of those bounds in the document. The group is marked as type's outlines
|
||||
/// ([`vectorcraft_doc::NodeExtras::type_outlines`]).
|
||||
@@ -542,8 +586,9 @@ pub fn outline_text(n: &Node) -> Option<Node> {
|
||||
continue;
|
||||
}
|
||||
let mut piece = Node::path(n.id, PathData::from_bezpath(bp), Appearance::default());
|
||||
paint_type_piece(&mut piece, &run.style, bounds, t.xf);
|
||||
let stroke = paint_type_piece(&mut piece, &run.style, bounds, t.xf);
|
||||
children.push(Arc::new(piece));
|
||||
children.extend(stroke.map(Arc::new));
|
||||
}
|
||||
if !n.appearance.items.is_empty() && !all.elements().is_empty() {
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
|
||||
@@ -11,6 +11,7 @@ mod rasterfollow;
|
||||
mod scale;
|
||||
mod solidcontext;
|
||||
mod transformfx;
|
||||
mod typestroke;
|
||||
|
||||
fn fx(id: &str, params: Value) -> Effect {
|
||||
Effect { id: id.into(), params, visible: true }
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Character strokes of outlined type under the type's transform ([`paint_type_piece`]): a live
|
||||
//! stroke with a scaled weight under a similarity, otherwise the area the stroke paints in text
|
||||
//! space, placed by the transform and filled.
|
||||
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Dash, LineJoin, Node, NodeId, StrokeAlign};
|
||||
use vectorcraft_geom::{Affine, FillRule, Rect, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
const BOX: Rect = Rect::new(0.0, 0.0, 10.0, 10.0);
|
||||
|
||||
fn red() -> Color {
|
||||
Color::rgb(1.0, 0.0, 0.0)
|
||||
}
|
||||
|
||||
/// A black run with a red stroke of `width` and round joins.
|
||||
fn style(width: f64) -> CharStyle {
|
||||
CharStyle {
|
||||
fill: Paint::solid(Color::BLACK),
|
||||
stroke: Paint::solid(red()),
|
||||
stroke_width: width,
|
||||
stroke_join: LineJoin::Round,
|
||||
..CharStyle::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A 10 x 10 square in text space standing in for a glyph.
|
||||
fn square() -> Node {
|
||||
Node::path(NodeId(7), shapes::rectangle(BOX), Appearance::default())
|
||||
}
|
||||
|
||||
fn area(n: &Node) -> f64 {
|
||||
vectorcraft_pathops::area(n.path_data().unwrap(), FillRule::NonZero).abs()
|
||||
}
|
||||
|
||||
fn near(a: Rect, b: Rect) -> bool {
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 0.05)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_uneven_scale_fills_the_stroke_the_canvas_paints() {
|
||||
let mut piece = square();
|
||||
let stroke = paint_type_piece(&mut piece, &style(4.0), BOX, Affine::scale_non_uniform(3.0, 1.0)).expect("a filled stroke");
|
||||
assert!(piece.appearance.stroke().is_none(), "the piece keeps only its fill");
|
||||
assert_eq!(piece.appearance.fill_paint(), Paint::solid(Color::BLACK));
|
||||
assert_eq!(piece.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 30.0, 10.0));
|
||||
assert!(stroke.appearance.stroke().is_none());
|
||||
assert_eq!(stroke.appearance.fill_paint(), Paint::solid(red()));
|
||||
assert_eq!(stroke.id, NodeId(7));
|
||||
// 4 pt across in text space: 12 pt across the upright sides and 4 pt across the level ones.
|
||||
let b = stroke.geometric_bounds().unwrap();
|
||||
assert!(near(b, Rect::new(-6.0, -2.0, 36.0, 12.0)), "{b:?}");
|
||||
// The ring of a 10 pt square stroked 4 pt with round joins (180 + 4π outside, 36 inside), 3 times as wide.
|
||||
let ring = 180.0 + 4.0 * std::f64::consts::PI - 36.0;
|
||||
assert!((area(&stroke) - 3.0 * ring).abs() < 0.5, "{}", area(&stroke));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_similarity_keeps_the_live_stroke_with_a_scaled_weight() {
|
||||
for (xf, weight) in [
|
||||
(Affine::translate((5.0, 5.0)), 4.0),
|
||||
(Affine::scale(2.0), 8.0),
|
||||
(Affine::rotate(0.5) * Affine::scale(2.0), 8.0),
|
||||
(Affine::scale_non_uniform(-2.0, 2.0), 8.0),
|
||||
] {
|
||||
let mut piece = square();
|
||||
assert!(paint_type_piece(&mut piece, &style(4.0), BOX, xf).is_none(), "{xf:?}");
|
||||
let st = piece.appearance.stroke().expect("a live stroke");
|
||||
assert!((st.width - weight).abs() < 1e-9, "{xf:?}: {}", st.width);
|
||||
assert_eq!(st.join, LineJoin::Round);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shears_and_flattening_transforms_fill_the_stroke() {
|
||||
// A shear keeps the area.
|
||||
let mut piece = square();
|
||||
let stroke = paint_type_piece(&mut piece, &style(4.0), BOX, Affine::skew(0.5, 0.0)).expect("a filled stroke");
|
||||
let ring = 180.0 + 4.0 * std::f64::consts::PI - 36.0;
|
||||
assert!((area(&stroke) - ring).abs() < 0.5, "{}", area(&stroke));
|
||||
// Flattened to a line or a point: no division by the determinant, nothing non-finite.
|
||||
for xf in [Affine::scale_non_uniform(2.0, 0.0), Affine::scale(0.0)] {
|
||||
let mut piece = square();
|
||||
let stroke = paint_type_piece(&mut piece, &style(4.0), BOX, xf).expect("a filled stroke");
|
||||
assert!(piece.appearance.stroke().is_none());
|
||||
if let Some(b) = stroke.geometric_bounds() {
|
||||
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.height().abs() < 1e-9, "{xf:?}: {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stroke_options_shape_the_filled_stroke_in_text_space() {
|
||||
let xf = Affine::scale_non_uniform(3.0, 1.0);
|
||||
let filled = |st: CharStyle| paint_type_piece(&mut square(), &st, BOX, xf);
|
||||
// No stroke, or no weight: nothing is filled.
|
||||
assert!(filled(style(0.0)).is_none());
|
||||
assert!(filled(CharStyle { stroke: Paint::None, ..style(4.0) }).is_none());
|
||||
// Inside and outside alignment are kept to their side of the square before it is scaled.
|
||||
let inside = filled(CharStyle { stroke_align: StrokeAlign::Inside, ..style(4.0) }).unwrap();
|
||||
assert!(near(inside.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 30.0, 10.0)));
|
||||
assert!((area(&inside) - 3.0 * (100.0 - 4.0)).abs() < 0.5, "{}", area(&inside));
|
||||
let outside = filled(CharStyle { stroke_align: StrokeAlign::Outside, ..style(4.0) }).unwrap();
|
||||
assert!(near(outside.geometric_bounds().unwrap(), Rect::new(-12.0, -4.0, 42.0, 14.0)), "{:?}", outside.geometric_bounds());
|
||||
// Dashes are laid out along the square in text space: half of each 10 pt is painted.
|
||||
let solid = area(&filled(style(1.0)).unwrap());
|
||||
let dash = Dash { pattern: vec![5.0, 5.0], ..Default::default() };
|
||||
let dashed = area(&filled(CharStyle { stroke_dash: Some(dash), ..style(1.0) }).unwrap());
|
||||
assert!(dashed < 0.7 * solid && dashed > 0.3 * solid, "{dashed} of {solid}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_on_the_stroke_stays_where_the_stroke_had_it() {
|
||||
let stops = vec![GradientStop::new(0.0, red()), GradientStop::new(1.0, Color::BLACK)];
|
||||
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||
let mut piece = square();
|
||||
let stroke = paint_type_piece(&mut piece, &CharStyle { stroke: ramp, ..style(4.0) }, BOX, Affine::scale_non_uniform(3.0, 1.0)).unwrap();
|
||||
let Paint::Gradient(g) = stroke.appearance.fill_paint() else { panic!("a gradient fill") };
|
||||
// Fitted across the stroke's box in text space (the square grown by half the weight), then scaled.
|
||||
let geom = g.geom.expect("placed");
|
||||
assert!((geom.start.x + 6.0).abs() < 1e-6 && (geom.end.x - 36.0).abs() < 1e-6, "{geom:?}");
|
||||
}
|
||||
@@ -265,18 +265,21 @@ 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 with its unplaced gradients fitted to the layout's bounds, has one
|
||||
/// bars, each painted as its run with its unplaced gradients fitted to the layout's bounds, or the
|
||||
/// path filled with its run's stroke under a transform that is not a similarity, 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(_))) {
|
||||
let gradient =
|
||||
|st: &vectorcraft_doc::CharStyle| matches!(st.fill, Paint::Gradient(_)) || (st.has_stroke() && matches!(st.stroke, Paint::Gradient(_)));
|
||||
if !t.runs.iter().any(|r| gradient(&r.style)) {
|
||||
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_or(&first, |r| &r.style);
|
||||
if !matches!(style.fill, Paint::Gradient(_)) {
|
||||
if !gradient(style) {
|
||||
return false;
|
||||
}
|
||||
let path = PathData::from_bezpath(outline);
|
||||
@@ -284,8 +287,8 @@ fn text_gradient_expands(d: &Document, t: &TextObject) -> bool {
|
||||
return false;
|
||||
}
|
||||
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 stroke = vectorcraft_render::effects::paint_type_piece(&mut piece, style, lay.bounds, t.xf);
|
||||
gradient_expands(d, &piece) || stroke.is_some_and(|s| gradient_expands(d, &s))
|
||||
};
|
||||
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))
|
||||
|
||||
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Outlines",
|
||||
["Type"],
|
||||
Some("Cmd+Shift+O"),
|
||||
"{} 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; the group keeps the object's transparency, opacity mask and effects, once; 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); on type scaled unevenly, sheared or flattened, each glyph's stroke becomes a filled path over it, shaped as the canvas strokes it; 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; the group keeps the object's transparency, opacity mask and effects, once; 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
|
||||
),
|
||||
@@ -619,16 +619,25 @@ fn scale_text_attributes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// 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<Node> {
|
||||
/// transform), followed by a path (or compound path) filled with the run's stroke when the type's
|
||||
/// transform is not a similarity; empty when the piece has no path.
|
||||
fn type_piece(d: &mut Document, t: &TextObject, bounds: kurbo::Rect, run: usize, outline: &kurbo::BezPath) -> Vec<Node> {
|
||||
let path = PathData::from_bezpath(outline);
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
return vec![];
|
||||
}
|
||||
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)
|
||||
let stroke = vectorcraft_render::effects::paint_type_piece(&mut node, &st, bounds, t.xf);
|
||||
let mut out = vec![node];
|
||||
if let Some(s) = stroke
|
||||
&& let Some(path) = s.path_data()
|
||||
{
|
||||
let mut filled = shape_node(d, path.clone(), None);
|
||||
filled.appearance = s.appearance.clone();
|
||||
out.push(filled);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
@@ -675,9 +684,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
children.push(Arc::new(d.reid(&art)));
|
||||
break 'glyph;
|
||||
}
|
||||
if let Some(node) = type_piece(d, t, lay.bounds, g.run, &g.outline) {
|
||||
children.push(Arc::new(node));
|
||||
}
|
||||
children.extend(type_piece(d, t, lay.bounds, g.run, &g.outline).into_iter().map(Arc::new));
|
||||
}
|
||||
run_end.insert(g.run, children.len());
|
||||
}
|
||||
@@ -686,10 +693,9 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// nothing there either).
|
||||
let mut ruby: Vec<(usize, Arc<Node>)> = vec![];
|
||||
for g in &lay.ruby {
|
||||
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)));
|
||||
ruby.extend(type_piece(d, t, lay.bounds, g.run, &g.outline).into_iter().map(|node| (at, Arc::new(node))));
|
||||
}
|
||||
ruby.sort_by_key(|(at, _)| *at);
|
||||
let mut ruby = ruby.into_iter().peekable();
|
||||
@@ -711,9 +717,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// 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());
|
||||
if let Some(node) = type_piece(d, t, lay.bounds, run, &bar) {
|
||||
children.push(Arc::new(node));
|
||||
}
|
||||
children.extend(type_piece(d, t, lay.bounds, run, &bar).into_iter().map(Arc::new));
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -188,6 +188,77 @@ fn align_stroke_reaches_the_characters_stroke() {
|
||||
assert!(aligns.contains(&StrokeAlign::Inside) && aligns.contains(&StrokeAlign::Outside), "{aligns:?}");
|
||||
}
|
||||
|
||||
/// The bounds of type `t`'s glyph outlines in text space.
|
||||
fn glyph_bounds(t: &TextObject) -> kurbo::Rect {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let mut all = kurbo::BezPath::new();
|
||||
for g in &lay.glyphs {
|
||||
all.extend(g.outline.iter());
|
||||
}
|
||||
kurbo::Shape::bounding_box(&all)
|
||||
}
|
||||
|
||||
/// Create Outlines on type scaled 300% across and 100% down: the canvas strokes the characters in
|
||||
/// text space and then scales them, so the outlined stroke is filled art three times as thick
|
||||
/// across upright stems as across level bars, not a live stroke of one mean weight.
|
||||
#[test]
|
||||
fn create_outlines_keeps_the_character_strokes_thickness_under_an_uneven_scale() {
|
||||
let mut s = session();
|
||||
let id = stroked_text(&mut s);
|
||||
run(&mut s, "stroke.set", json!({"weight": 4, "join": "round", "cap": "round"}));
|
||||
run(&mut s, "object.scale", json!({"ids": [id.0], "sx": 300, "sy": 100}));
|
||||
let t = text(&s, id);
|
||||
let [a, b, c, d, ..] = t.xf.as_coeffs();
|
||||
assert!((a - 3.0).abs() < 1e-9 && b.abs() < 1e-9 && c.abs() < 1e-9 && (d - 1.0).abs() < 1e-9, "{:?}", t.xf);
|
||||
let w = t.runs[0].style.stroke_width;
|
||||
let gb = glyph_bounds(&t);
|
||||
run(&mut s, "select.set", json!({ "ids": [id.0] }));
|
||||
let g = run(&mut s, "type.createOutlines", json!({}))["ids"][0].as_u64().unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let group = doc.node(NodeId(g)).unwrap();
|
||||
let mut strokes: Option<kurbo::Rect> = None;
|
||||
for c in group.children().unwrap() {
|
||||
assert!(c.appearance.stroke().is_none(), "no live stroke of one weight is left");
|
||||
if c.appearance.fill_paint() == Paint::solid(Color::rgb8(255, 0, 0)) {
|
||||
let cb = c.geometric_bounds().unwrap();
|
||||
strokes = Some(strokes.map_or(cb, |s| s.union(cb)));
|
||||
}
|
||||
}
|
||||
// Round joins and caps reach half the weight past the glyphs in text space, then scale.
|
||||
let want = t.xf.transform_rect_bbox(gb.inflate(w / 2.0, w / 2.0));
|
||||
let got = strokes.expect("filled character strokes");
|
||||
assert!((got.width() - want.width()).abs() < 0.1 && (got.height() - want.height()).abs() < 0.1, "{got:?} for {want:?}");
|
||||
assert!((got.height() - (gb.height() + w)).abs() < 0.1, "level bars keep the weight: {got:?}");
|
||||
}
|
||||
|
||||
/// Under a uniform scale and a rotation the outlined characters keep a live stroke, its weight
|
||||
/// and dashes scaled with them.
|
||||
#[test]
|
||||
fn create_outlines_keeps_a_live_character_stroke_under_a_uniform_scale_and_rotation() {
|
||||
let mut s = session();
|
||||
let id = stroked_text(&mut s);
|
||||
run(&mut s, "stroke.set", json!({"weight": 4, "dash": [6, 2]}));
|
||||
run(&mut s, "object.scale", json!({"ids": [id.0], "sx": 200, "sy": 200}));
|
||||
run(&mut s, "object.rotate", json!({"angle": 30}));
|
||||
let t = text(&s, id);
|
||||
let k = t.xf.determinant().abs().sqrt();
|
||||
assert!((k - 2.0).abs() < 1e-9, "{:?}", t.xf);
|
||||
let st = t.runs[0].style.clone();
|
||||
let dash = st.stroke_dash.as_ref().unwrap().pattern.clone();
|
||||
let glyphs = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).glyphs.len();
|
||||
run(&mut s, "select.set", json!({ "ids": [id.0] }));
|
||||
let g = run(&mut s, "type.createOutlines", json!({}))["ids"][0].as_u64().unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let pieces = doc.node(NodeId(g)).unwrap().children().unwrap().to_vec();
|
||||
assert_eq!(pieces.len(), glyphs, "one path for each glyph, no filled strokes");
|
||||
for p in &pieces {
|
||||
let ps = p.appearance.stroke().expect("a live stroke");
|
||||
assert!((ps.width - st.stroke_width * k).abs() < 1e-6, "{} for {}", ps.width, st.stroke_width * k);
|
||||
let got = &ps.dash.as_ref().unwrap().pattern;
|
||||
assert!(got.iter().zip(&dash).all(|(g, d)| (g - d * k).abs() < 1e-6), "{got:?} for {dash:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Copying type's appearance onto a path keeps its characters' stroke alignment (the Eyedropper's
|
||||
/// own stroke attributes have none, on paths as on type).
|
||||
#[test]
|
||||
|
||||
@@ -717,6 +717,29 @@ fn type_reports_a_gradient_when_any_glyph_expands() {
|
||||
assert_eq!(gradients_and_type(&s), (1, 0), "the large glyph keeps its gradient");
|
||||
}
|
||||
|
||||
/// Type scaled unevenly outlines its characters' stroke as a filled path, so a gradient on that
|
||||
/// stroke becomes a gradient fill: the query reports it, and Expand turns it into art.
|
||||
#[test]
|
||||
fn type_reports_a_stroke_gradient_its_uneven_scale_turns_into_a_fill() {
|
||||
let mut s = session();
|
||||
let t = run(&mut s, "text.create", json!({"x": 50, "y": 100, "text": "Hi", "size": 48}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
run(
|
||||
&mut s,
|
||||
"paint.setStroke",
|
||||
json!({"gradient": {"kind": "linear", "spread": "repeat", "repeats": 100, "start": [50, 100], "end": [70, 100],
|
||||
"stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]}}),
|
||||
);
|
||||
run(&mut s, "stroke.set", json!({"weight": 4}));
|
||||
let info = run(&mut s, "object.expand.info", json!({}));
|
||||
assert_eq!(info["gradient"], json!(false), "a live stroke's gradient is not a fill");
|
||||
run(&mut s, "object.scale", json!({"ids": [t], "sx": 300, "sy": 100}));
|
||||
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), (0, 0), "the filled strokes' gradients became art");
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
||||
+5
-1
@@ -9188,7 +9188,11 @@ indent and bullet or number again.
|
||||
```
|
||||
|
||||
`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
|
||||
strikethrough bar, each painted as its characters are. When the type is scaled unevenly, sheared or flattened, each of
|
||||
these paths takes only its characters' fill, and a path filled with the stroke's paint follows it: the area the
|
||||
characters' stroke paints around the glyphs before the type's transform (dashes, caps, joins and alignment), transformed
|
||||
with them as the canvas draws it. Under a move, rotation, reflection or uniform scale the paths keep a live stroke, its
|
||||
weight and dashes scaled with the type. 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
|
||||
|
||||
@@ -706,6 +706,7 @@ since 2026-10-01.
|
||||
- **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, <https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=337>); a type object's appearance is separate from its characters' (the help page, <https://helpx.adobe.com/illustrator/using/appearance-attributes.html>); 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 type object's own effects and transparency:** Type › Create Outlines gives the group of outlines the type object's own effects, once, in their order, with their settings and hidden effects still hidden, so they stay live and editable and the outlines draw what the type drew (a Warp or Free Distort effect moves a gradient on the characters across the whole outline, as on the type); the effects of the object's own fills and strokes stay on their paths of the whole outline. The group also takes the object's opacity, blend mode, Isolate Blending, Opacity & Mask Define Knockout Shape and opacity mask. Outlined characters keep their stroke and fill (the help page, <https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=365>); putting the object's effects on the group is VectorCraft's own rule. Measured by `tests::pathops::create_outlines_keeps_the_objects_own_effects_on_the_group`, `tests::pathops::create_outlines_keeps_the_objects_transparency_and_opacity_mask`, `tests::objexpand::create_outlines_draws_the_type_objects_own_effects` and `tests::objexpand::create_outlines_keeps_a_warped_character_gradient_across_the_type`. 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, <https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=337>); 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).
|
||||
- **Create Outlines keeps the characters' stroke on unevenly scaled type:** on type scaled unevenly, sheared or flattened, Type › Create Outlines, Object › Expand and geometry effects on the type turn each character stroke into a filled path of the area the canvas strokes: laid out around the glyphs before the type's transform (dashes, caps, joins and inside or outside alignment), then transformed with them, so a stroke scaled 300% across is three times as thick across upright stems as across level bars. Moved, rotated, reflected and uniformly scaled type keeps a live stroke with its weight and dashes scaled. `object.expand.info` reports a gradient on such a filled stroke. Outlined characters keep their stroke and fill (the help page, <https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=337>); turning the stroke into a filled path only under such transforms is VectorCraft's own rule. Measured by `tests::charstroke::create_outlines_keeps_the_character_strokes_thickness_under_an_uneven_scale`, `tests::charstroke::create_outlines_keeps_a_live_character_stroke_under_a_uniform_scale_and_rotation`, `tests::objexpand::type_reports_a_stroke_gradient_its_uneven_scale_turns_into_a_fill` and `tests::typestroke` (`crates/effects`). 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).
|
||||
- **Composite fonts' custom character sets:** Type › Composite Fonts… › Custom adds named character sets to a composite font, each setting the characters it lists in its own font, style, size, baseline and scales ahead of their class; the later of two sets that list a character sets it ([the help page](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348)). Characters are added by Direct Entry or by Unicode code and deleted one at a time; the sets are saved with the document and in `.vccomposite` files (`custom` in `type.compositeFonts.set`). Evidence: `tests::composite::custom_character_sets_take_their_characters_first` (`crates/text`), `compositefonts::tests::custom_character_sets_set_their_characters` (`crates/engine`), `dialogs::composite_fonts::tests::custom_character_sets_are_edited_in_the_dialog` (`crates/ui-egui`). Scores unchanged (estimated).
|
||||
|
||||
Reference in New Issue
Block a user