Merge pull request #2214 from dotCooCoo/feat/outline-object-type-art

Type: type drawn as glyph outlines keeps its underline, stroke weight, gradients and patterns
This commit is contained in:
BFlat
2026-10-16 03:17:22 +07:00
committed by GitHub
9 changed files with 454 additions and 10 deletions
+11
View File
@@ -543,6 +543,9 @@ pub struct NodeExtras {
/// Index of the graph series this generated group belongs to. Axes, the legend and other
/// children leave it unset, so a series named "Legend" or "Axes" is not mistaken for them.
pub series_index: Option<u32>,
/// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale
/// the placed gradients in it with the outlines.
pub type_outlines: bool,
/// Editable Shaper composition; the original art is retained in its source child.
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
@@ -567,6 +570,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
wrap: None,
graph: None,
series_index: None,
type_outlines: false,
shaper: None,
graphic_style: None,
attrs: None,
@@ -696,6 +700,7 @@ impl Serialize for Node {
e.wrap.is_none(),
e.graph.is_none(),
e.series_index.is_none(),
is_default(&e.type_outlines),
e.shaper.is_none(),
false,
e.graphic_style.is_none(),
@@ -731,6 +736,7 @@ impl Serialize for Node {
field!("wrap", &e.wrap);
field!("graph", &e.graph);
field!("series_index", &e.series_index);
field!("type_outlines", &e.type_outlines);
field!("shaper", &e.shaper);
field!("kind", &self.kind);
field!("graphic_style", &e.graphic_style);
@@ -778,6 +784,8 @@ struct NodeWire {
#[serde(default)]
series_index: Option<u32>,
#[serde(default)]
type_outlines: bool,
#[serde(default)]
shaper: Option<Box<crate::shaper::ShaperSpec>>,
kind: NodeKind,
#[serde(default)]
@@ -802,6 +810,7 @@ impl From<NodeWire> for Node {
wrap: w.wrap,
graph: w.graph,
series_index: w.series_index,
type_outlines: w.type_outlines,
shaper: w.shaper,
graphic_style: w.graphic_style,
attrs: w.attrs,
@@ -1938,6 +1947,7 @@ mod tests {
e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true)));
e.trace = Some(Box::new(serde_json::json!({"preset": "p"})));
e.series_index = Some(2);
e.type_outlines = true;
e.graphic_style = Some(3);
e.attrs = Some(Box::default());
e.slice = Some(Box::default());
@@ -1976,6 +1986,7 @@ mod tests {
"mask",
"trace",
"series_index",
"type_outlines",
"kind",
"graphic_style",
"attrs",
+26
View File
@@ -371,4 +371,30 @@ mod tests {
assert!(pathfinder_children(&g, None).is_none());
assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK)));
}
#[test]
fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
// The type's characters fit the gradient to its layout; the square has it placed across itself.
let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style);
let t = Arc::new(Node::new(NodeId(3), NodeKind::Text(Box::new(text))));
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
let square = Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed, Paint::None, 0.0)));
let start = |n: &Node| match n.appearance.fill_paint() {
Paint::Gradient(g) => g.geom.unwrap().start.x,
p => panic!("a gradient: {p:?}"),
};
let before = start(&crate::outline_text(&t).unwrap().children().unwrap()[0]);
let mut g = Node::group(NodeId(10), vec![square, t]);
g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
let art = evaluate_container(&g).unwrap();
let members = art.children().unwrap();
let after = start(&members[1].children().unwrap()[0]);
assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}");
assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed");
}
}
+120 -8
View File
@@ -30,9 +30,17 @@ pub fn needs_outline(n: &Node) -> bool {
)
}
/// The glyph outlines of type `t` in text space: one path per run, and all of them as one.
/// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one
/// path per run, and all of them as one.
pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let (runs, all, _) = laid_out(t);
(runs, all)
}
/// [`glyph_outlines`] and the bounds of `t`'s layout in text space.
fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath, Rect) {
let db = vectorcraft_text::FontDb::global();
let layout = vectorcraft_text::layout(db, t);
let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
let mut all = kurbo::BezPath::new();
for g in &layout.glyphs {
@@ -41,25 +49,49 @@ pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::Bez
}
all.extend(g.outline.iter());
}
(runs, all)
// Underline and strikethrough bars, painted as their run's type is (#847).
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
if let Some(r) = runs.get_mut(run) {
r.extend(bar.iter());
}
all.extend(bar.iter());
}
(runs, all, layout.bounds)
}
/// Type as glyph outlines in document space: one path per run with the run's fill and stroke,
/// 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.
/// 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 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`]).
pub fn outline_text(n: &Node) -> Option<Node> {
let NodeKind::Text(t) = &n.kind else { return None };
let (runs, all) = glyph_outlines(t);
let (runs, all, bounds) = 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<Arc<Node>> = runs
.iter()
.zip(&t.runs)
.filter(|(bp, _)| !bp.elements().is_empty())
.map(|(bp, run)| Arc::new(Node::path(n.id, path(bp), run.style.appearance())))
.map(|(bp, run)| {
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);
Arc::new(piece)
})
.collect();
if !n.appearance.items.is_empty() && !all.elements().is_empty() {
let (below, above) = n.appearance.split_contents();
let glyphs = |items: &[AppearanceItem]| Arc::new(Node::path(n.id, path(&all), Appearance { items: items.to_vec(), ..Default::default() }));
let tb = t.xf.transform_rect_bbox(bounds);
let glyphs = |items: &[AppearanceItem]| {
let mut ap = Appearance { items: items.to_vec(), ..Default::default() };
ap.pin_gradients(tb);
Arc::new(Node::path(n.id, path(&all), ap))
};
if !below.is_empty() {
children.insert(0, glyphs(below));
}
@@ -67,7 +99,9 @@ pub fn outline_text(n: &Node) -> Option<Node> {
children.push(glyphs(above));
}
}
Some(Node::group(n.id, children))
let mut g = Node::group(n.id, children);
g.extras_mut().type_outlines = true;
Some(g)
}
/// `n` as plain art that geometry effects can reshape, without its transparency and object
@@ -215,6 +249,10 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
/// them as they are (a raster follows a non-affine map only as a mesh warp).
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
let seed = art.id.0;
// The placed gradients of type's outlines in `art` (`outline_text`) move and scale with the
// pieces, from the outlines' bounds before the effects to their bounds after them.
let mut outlines = vec![];
type_outline_bounds(art, &mut outlines);
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
@@ -228,6 +266,80 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
transform_images(art, m, copies);
}
}
if !fx.is_empty() && outlines.iter().any(Option::is_some) {
rebase_type_outlines(art, &mut outlines.into_iter());
}
}
/// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in
/// order. A group inside one of them is part of it.
fn type_outline_bounds(n: &Node, out: &mut Vec<Option<Rect>>) {
if n.extras().type_outlines {
out.push(n.geometric_bounds());
} else if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &n.kind {
for c in children {
type_outline_bounds(c, out);
}
}
}
/// Move and scale the placed gradients of each group of type's outlines under `n` from its bounds
/// in `before` ([`type_outline_bounds`] before its paths changed) to its bounds now.
fn rebase_type_outlines(n: &mut Node, before: &mut std::vec::IntoIter<Option<Rect>>) {
if !n.extras().type_outlines {
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind {
for c in children.iter_mut() {
rebase_type_outlines(Arc::make_mut(c), before);
}
}
} else if let Some(Some(from)) = before.next()
&& let Some(to) = n.geometric_bounds()
&& from != to
{
for_each_leaf(n, &mut |leaf| {
if leaf.appearance.has_placed_gradient() {
follow_boxes(&mut leaf.appearance, from, to);
}
});
}
}
/// Map the placed gradients of `ap` from box `from` to box `to` through the map that takes one box
/// onto the other (strokes: their stroke-inflated boxes). The start and end go where
/// [`Appearance::rebase_gradients`] puts them, and a radial's ellipse and a linear's isolines are
/// mapped with them.
fn follow_boxes(ap: &mut Appearance, from: Rect, to: Rect) {
for i in &mut ap.items {
match i {
AppearanceItem::Fill(f) => {
if let vectorcraft_doc::color::Paint::Gradient(g) = &mut f.paint {
g.transform(box_map(from, to));
}
}
AppearanceItem::Stroke(s) => {
let m = box_map(s.paint_bounds(from), s.paint_bounds(to));
if let vectorcraft_doc::color::Paint::Gradient(g) = &mut s.paint {
g.transform(m);
}
}
}
}
}
/// The map that takes box `from` onto box `to` along each axis (along an axis `from` has no extent
/// on, a move from its center to `to`'s).
fn box_map(from: Rect, to: Rect) -> vectorcraft_geom::Affine {
let axis = |a0: f64, a1: f64, b0: f64, b1: f64| {
if (a1 - a0).abs() > 1e-12 {
let s = (b1 - b0) / (a1 - a0);
(s, b0 - a0 * s)
} else {
(1.0, (b0 + b1 - a0 - a1) / 2.0)
}
};
let (sx, tx) = axis(from.x0, from.x1, to.x0, to.x1);
let (sy, ty) = axis(from.y0, from.y1, to.y0, to.y1);
vectorcraft_geom::Affine::new([sx, 0.0, 0.0, sy, tx, ty])
}
/// Apply the Transform effect's map `m` to every image under `n`, as [`for_each_leaf`] does to
+13
View File
@@ -181,6 +181,19 @@ fn type_with_a_shadow_stays_type_and_type_with_its_own_stroke_is_outlined() {
assert!(!expandable(&s));
}
#[test]
fn type_with_its_own_stroke_expands_with_its_underline() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 160, "text": "Hi", "size": 48}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "text.setFormat", json!({"underline": true}));
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "effect.expandAppearance", json!({}));
// "Hi" has no ink below its baseline (y 160): the pieces reach the bar under it.
let b = node(&s, t).geometric_bounds().unwrap();
assert!(b.y1 > 162.0, "{b:?}");
}
#[test]
fn a_blurred_layer_keeps_its_image_as_its_only_member() {
let mut s = session();
+42
View File
@@ -355,6 +355,48 @@ fn objects_without_transparency_stay_unless_outlined() {
}
}
#[test]
fn outlined_type_keeps_its_underline() {
let _settings = settings_lock();
let mut s = session();
let text = run(&mut s, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 24}))["id"].as_u64().unwrap();
run(&mut s, "text.setFormat", json!({"ids": [text], "underline": true}));
let (ids, ..) = flatten(&mut s, json!({"ids": [text], "textToOutlines": true}));
// "Hi" has no ink below its baseline (y 150): the outlines reach the bar under it.
let b = node(&s, ids[0]).geometric_bounds().unwrap();
assert!(b.y1 > 151.0, "{b:?}");
}
#[test]
fn outlined_type_keeps_its_gradient_on_the_outlines_its_transform_effect_moves() {
let _settings = settings_lock();
// "IIII" at 80 pt with its baseline at (x, y), its characters filled with a red-to-blue gradient
// that runs left to right, with a Transform effect of `params` if any.
let typed = |x: f64, y: f64, params: Option<Value>| {
let mut s = session();
let t = run(&mut s, "text.create", json!({"x": x, "y": y, "text": "IIII", "size": 80}))["id"].as_u64().unwrap();
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
run(&mut s, "paint.setFill", json!({"ids": [t], "gradient": {"kind": "linear", "stops": stops}}));
if let Some(params) = params {
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": params, "ids": [t], "item": null}));
}
(s, t)
};
let mut diffs = vec![];
// The effect moves the type 30 pt right (along the gradient) or 20 pt down (across it). Type
// laid out at the moved baseline is the reference.
for (dx, dy) in [(30.0, 0.0), (0.0, 20.0)] {
let reference = picture(&typed(10.0 + dx, 150.0 + dy, None).0);
let (mut s, t) = typed(10.0, 150.0, Some(json!({"moveH": dx, "moveV": dy})));
flatten(&mut s, json!({"ids": [t], "textToOutlines": true}));
diffs.push(changed(&picture(&s), &reference));
// The outlines draw the same after the document is saved and opened again.
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
diffs.push(changed(&Renderer::new().render_region(&back, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true), &reference));
}
assert!(diffs.iter().all(|&d| d < 0.0002), "shares of the pixels that changed: {diffs:?}");
}
#[test]
fn preserve_alpha_composites_over_nothing() {
let _settings = settings_lock();
+16
View File
@@ -84,6 +84,22 @@ fn a_group_fill_paints_every_member_above_or_below_them() {
assert_eq!(img.pixel(25, 25), RED);
}
#[test]
fn a_group_fill_paints_the_underline_of_its_type() {
// The group holds underlined black "IIII" at 40 pt with its baseline at y 60, and has a green fill.
let mut d = Document::new(100.0, 100.0);
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), underline: true, ..Default::default() };
let t = Arc::new(Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style)))));
let mut g = Node::group(d.alloc_id(), vec![t]);
green_fill(&mut g);
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
// The glyphs have no ink below the baseline: the bar's pixels there are green, none black.
let img = render(&d);
let bar: Vec<[u8; 4]> = (61..70).flat_map(|y| (0..100).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)).filter(|p| p[0] < 160).collect();
assert!(bar.len() > 50 && bar.iter().all(|p| p[1] > p[0] + 20 && p[1] > p[2] + 20), "{bar:?}");
}
#[test]
fn a_group_fill_with_opacity_composites_as_one_layer() {
// Two overlapping members under a 50 % group fill: the overlap is as light as the rest.
+214 -2
View File
@@ -5,9 +5,10 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Appearance, Effect, ImageBlob, ImageObject, Node, NodeKind, Symbol};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, ImageBlob, ImageObject, Node, NodeKind, Symbol};
use vectorcraft_geom::{Point, shapes};
use super::*;
@@ -317,6 +318,217 @@ fn inked_columns(img: &Rendered) -> Vec<u32> {
(0..100).filter(|x| (0..100).any(|y| lum(img.pixel(*x, y)) < 300)).collect()
}
/// "IIII" in `style` with its baseline at (5, 60), its text space scaled or turned there by `xf`.
fn styled_text_doc(style: CharStyle, xf: Affine) -> Document {
let mut t = TextObject::point(Point::new(5.0, 60.0), "IIII", style);
t.xf *= xf;
doc_with(Document::new(100.0, 100.0), Node::new(NodeId(0), NodeKind::Text(Box::new(t))))
}
/// Pixels whose color differs by more than 8 levels in a channel between `a` and `b`.
fn differing(a: &Rendered, b: &Rendered) -> usize {
a.pixels
.as_chunks::<4>()
.0
.iter()
.zip(b.pixels.as_chunks::<4>().0)
.filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 8))
.count()
}
/// Pixels that change by more than 8 levels in a channel when the first object of `d` gets Offset
/// Path at 0 pt, a geometry effect that returns its input: the object is then drawn through its
/// outlines.
fn changed_by_outlining(d: &Document) -> usize {
let mut outlined = d.clone();
let id = outlined.layers[0].children().unwrap()[0].id;
outlined.node_mut(id).unwrap().appearance.effects.push(fx("path.offsetPath", json!({"offset": 0})));
differing(&render(d), &render(&outlined))
}
#[test]
fn type_drawn_through_its_outlines_keeps_its_underline() {
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), underline: true, ..Default::default() };
let d = styled_text_doc(style, Affine::IDENTITY);
// "IIII" has no ink below its baseline: the bar is all that is drawn there.
let plain = render(&d);
let bar = (61..70).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| lum(plain.pixel(x, y)) < 300).count();
assert!(bar > 50, "{bar} pixels of the bar");
let changed = changed_by_outlining(&d);
assert!(changed < 10, "{changed} pixels changed");
}
#[test]
fn type_drawn_through_its_outlines_keeps_its_stroke_weight_when_scaled() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let style = CharStyle { size: 20.0, fill: Paint::solid(Color::BLACK), stroke: red, stroke_width: 2.0, ..Default::default() };
// The stroke is 2 pt wide in text space and 4 pt wide in the document.
let changed = changed_by_outlining(&styled_text_doc(style, Affine::scale(2.0)));
assert!(changed < 10, "{changed} pixels changed");
}
#[test]
fn type_drawn_through_its_outlines_keeps_its_gradients_in_place() {
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
g.angle = 90.0;
let ramp = Paint::Gradient(Box::new(g));
// On the characters, the gradient fits the layout box in text space and turns with the type.
let characters = |xf| styled_text_doc(CharStyle { size: 40.0, fill: ramp.clone(), ..Default::default() }, xf);
// On a fill of the type's own, it fits the bounding box of the layout box in the document.
let mut own = styled_text_doc(CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }, Affine::IDENTITY);
let id = own.layers[0].children().unwrap()[0].id;
own.node_mut(id).unwrap().appearance.items.push(AppearanceItem::Fill(FillLayer::new(ramp.clone())));
let changed: Vec<usize> = [characters(Affine::IDENTITY), characters(Affine::rotate(0.3)), own].iter().map(changed_by_outlining).collect();
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
}
/// "IIII" at 40 pt with its baseline at (5, 60), its characters filled with a red-to-blue gradient
/// that runs left to right (0°) or bottom to top (90°).
fn gradient_text_doc(angle: f64) -> Document {
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
g.angle = angle;
styled_text_doc(CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(g)), ..Default::default() }, Affine::IDENTITY)
}
/// The Transform effects the gradient tests apply, each with the transform that does the same to
/// type `t`: 30 pt right, 20 pt down, and 50 % about the middle of its outlines.
fn transforms_of(t: &Node) -> [(Value, Affine); 3] {
let middle = vectorcraft_effects::outline_text(t).unwrap().geometric_bounds().unwrap().center().to_vec2();
[
(json!({"moveH": 30}), Affine::translate((30.0, 0.0))),
(json!({"moveV": 20}), Affine::translate((0.0, 20.0))),
(json!({"scaleH": 50, "scaleV": 50}), Affine::translate(middle) * Affine::scale(0.5) * Affine::translate(-middle)),
]
}
/// A 100×100 document holding `content` in an envelope that leaves it as it is (an arc with no
/// bend), with Distort Appearance on or off.
fn envelope_doc(content: Node, distort_appearance: bool) -> Document {
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true };
let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance, ..Default::default() };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(content)],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options,
frame: Box::new(Affine::IDENTITY),
};
doc_with(Document::new(100.0, 100.0), Node::new(NodeId(0), envelope))
}
#[test]
fn a_transform_effect_moves_and_scales_the_gradients_of_type_with_its_outlines() {
let mut changed = vec![];
for angle in [0.0, 90.0] {
let d = gradient_text_doc(angle);
let id = d.layers[0].children().unwrap()[0].id;
// The type's own transform does the same as the Transform effect in the document it is
// compared with.
for (params, xf) in transforms_of(d.node(id).unwrap()) {
let (mut effect, mut own) = (d.clone(), d.clone());
effect.node_mut(id).unwrap().appearance.effects.push(fx("distort.transform", params));
own.node_mut(id).unwrap().transform(xf, false);
changed.push(differing(&render(&effect), &render(&own)));
}
}
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
}
#[test]
fn a_transform_effect_that_scales_type_out_of_proportion_scales_its_radial_and_diagonal_gradients() {
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let mut changed = vec![];
// A radial gradient turns into an ellipse, and a linear one at 45° turns its isolines.
for (kind, angle) in [(GradientKind::Radial, 0.0), (GradientKind::Linear, 45.0)] {
let mut g = GradientPaint::new(Gradient { kind, stops: stops.clone() });
g.angle = angle;
let d = styled_text_doc(CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(g)), ..Default::default() }, Affine::IDENTITY);
let id = d.layers[0].children().unwrap()[0].id;
let middle = vectorcraft_effects::outline_text(d.node(id).unwrap()).unwrap().geometric_bounds().unwrap().center().to_vec2();
// 50 % across and 150 % up and down, about the middle of the outlines.
let xf = Affine::translate(middle) * Affine::scale_non_uniform(0.5, 1.5) * Affine::translate(-middle);
let (mut effect, mut own) = (d.clone(), d.clone());
effect.node_mut(id).unwrap().appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 150})));
own.node_mut(id).unwrap().transform(xf, false);
changed.push(differing(&render(&effect), &render(&own)));
}
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
}
#[test]
fn a_transform_effect_on_a_group_symbol_or_envelope_moves_and_scales_the_gradients_of_its_type() {
// The gradient runs left to right, so a move to the right goes along it and a move down
// across it.
let d = gradient_text_doc(0.0);
let id = d.layers[0].children().unwrap()[0].id;
let mut t = d.node(id).unwrap().clone();
t.id = NodeId(500);
// The type in a group, as the art of a symbol, and in an envelope that leaves it as it is.
let mut symbols = Document::new(100.0, 100.0);
symbols.symbols.push(Symbol { name: "Type".into(), art: Arc::new(t.clone()) });
let holders = [
doc_with(Document::new(100.0, 100.0), Node::group(NodeId(0), vec![Arc::new(t.clone())])),
doc_with(symbols, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Type".into(), xf: Affine::IDENTITY })),
envelope_doc(t.clone(), false),
];
let mut changed = vec![];
// The Transform effect is on the group, the symbol instance or the envelope. The type's own
// transform does the same in the document it is compared with.
for (params, xf) in transforms_of(&t) {
let mut own = d.clone();
own.node_mut(id).unwrap().transform(xf, false);
let reference = render(&own);
for h in &holders {
let mut effect = h.clone();
let holder = effect.layers[0].children().unwrap()[0].id;
effect.node_mut(holder).unwrap().appearance.effects.push(fx("distort.transform", params.clone()));
changed.push(differing(&render(&effect), &reference));
}
}
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
}
#[test]
fn type_in_an_envelope_moves_and_scales_its_gradients_with_its_own_transform_effect() {
let d = gradient_text_doc(0.0);
let id = d.layers[0].children().unwrap()[0].id;
let mut changed = vec![];
for (params, xf) in transforms_of(d.node(id).unwrap()) {
let mut own = d.clone();
own.node_mut(id).unwrap().transform(xf, false);
let reference = render(&own);
let mut t = d.node(id).unwrap().clone();
t.id = NodeId(500);
t.appearance.effects.push(fx("distort.transform", params));
// The envelope leaves the type as it is, with Distort Appearance off and on.
for distort in [false, true] {
changed.push(differing(&render(&envelope_doc(t.clone(), distort)), &reference));
}
}
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
}
#[test]
fn type_drawn_through_its_outlines_keeps_its_pattern_tiles_on_the_type() {
// Pattern `Checks` is a red 2 × 2 square in a 4 × 4 tile.
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let mut def = PatternDef::new("Checks", vec![Arc::new(Node::path(NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 2.0, 2.0)), red))]);
def.tile = Rect::new(0.0, 0.0, 4.0, 4.0);
// The type is 40 pt in the document: 20 pt type scaled 2×, or 40 pt type turned.
let changed: Vec<usize> = [Affine::scale(2.0), Affine::rotate(0.3)]
.into_iter()
.map(|xf| {
let mut d = styled_text_doc(CharStyle { size: 40.0 / xf.determinant().sqrt(), fill: pattern_paint("Checks"), ..Default::default() }, xf);
d.patterns.push(def.clone());
changed_by_outlining(&d)
})
.collect();
assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}");
}
#[test]
fn a_shadow_on_an_image_paints_offset_pixels() {
let plain = render(&image_doc(vec![]));
+10
View File
@@ -4854,3 +4854,13 @@ theme Appearance Външен вид
@msg Unknown dimension style Непознат стил на размера
@msg Unknown unit Непозната единица
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Затваря кръговите краища на отворени профили. При частично завъртане срязаните страни остават отворени.
# Strings added with Drop Shadow Darkness, Isolate Selected Object, Pencil Keep selected, Sample Color from Screen, Tritanopia proofing, Panels Except Tools and Control
Darkness Затъмняване
Isolate Selected Object Изолиране на избрания обект
Keep selected Запазване на избора
Sample Color from Screen Взимане на цвят от екрана
The active document changed while the screen color sampler was open Активният документ се промени, докато взимането на цвят от екрана беше отворено
This system has no screen color sampler Тази система няма инструмент за взимане на цвят от екрана
Color blindness – Tritanopia-type Цветна слепота – тип тританопия
Panels Except Tools and Control Панели без инструменти и контрол
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -581,6 +581,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Type with transparency or effects:** type at less than 100% opacity, in a blend mode other than Normal, or with raster effects such as a drop shadow keeps its glyph outlines between frames, as opaque type does, and is laid out again when it is edited or the fonts change. At 2880 × 1800 on 8 threads, a page of 1,000 type objects takes 13 ms a frame instead of 23 ms at 80% opacity, 31 ms instead of 41 ms in Multiply and 16 ms instead of 26 ms with a drop shadow, with the same pixels.
- **Type drawn as glyph outlines:** under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, flattened with Convert All Text to Outlines, and inside an envelope, type keeps its underline and strikethrough, the weight and dashes of its character strokes when it is scaled, and the placement of its gradients and patterns, as the canvas draws it. A group's or layer's own fills and strokes also paint the underline and strikethrough of the type in it.
- **Texture redraws (#1257):** Craquelure, Mosaic Tiles and Stained Glass compare squared distances when finding their nearest cells; rounding/tie boundaries use the original distance ordering. Brute-force candidate tests and three 1800×1500 viewport comparisons preserve the old pixels. A CMYK rectangle move at 190.07%/DPI 1.5 is reproducible with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`; idle-machine Windows interaction is still unverified.
- **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it.