mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Type: type drawn as glyph outlines keeps its underline, stroke weight, gradients and patterns
Type is drawn as its glyph outlines under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, through Flatten Transparency with Convert All Text to Outlines, and inside an envelope. Those outlines differed from the type the canvas draws in four ways. They had no underline or strikethrough bars. A character stroke on scaled type kept its unscaled weight. An unplaced gradient was fitted to the glyphs it painted instead of the layout box. A run's pattern tiles stayed in document space instead of scaling and turning with the type. A group's or layer's own fills and strokes also painted the glyphs of the type in it without the bars. The outlines now include each run's underline and strikethrough bars, as the canvas has drawn them since #850. Each run's path is made in text space with its unplaced gradients fixed to the layout box, then placed by the type's transform, which also scales its stroke weight and dashes and transforms its pattern tiles. The type's own fills and strokes have their unplaced gradients fixed to the bounding box of the layout box in the document, where the canvas fits them. Geometry effects that move or scale the outlines move and scale the type's gradients with them: the type's own effects, also inside an envelope and after Flatten Transparency has converted the type to outlines, and the effects of a group, symbol instance or envelope that holds the type. outline_text marks its group with a new node field, type_outlines, which is saved with the document. reshape_leaves maps the placed gradients in each marked group through the map that takes the group's bounds before the effects onto its bounds after them. A scale out of proportion stretches a radial gradient into an ellipse and turns the isolines of a diagonal linear gradient, as it does on type scaled by its own transform. Placed gradients outside those groups stay where they are.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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![]));
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user