M8.16: Enveloped type exports distorted, run by run, everywhere

Every exporter (SVG, PDF, EPS, EMF/WMF, DXF) evaluated live objects with no
text outliner, so type inside an envelope was written undistorted. They now
share vectorcraft_effects::expand_live_deep, which outlines type per run in
each run's paint, as the canvas now does too (it used the first run's paint
for the whole line).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-06 10:27:08 +07:00
co-authored by Claude Opus 5.5
parent 3bb1431422
commit 42be928367
11 changed files with 99 additions and 36 deletions
+1 -1
View File
@@ -316,7 +316,7 @@ impl<'a> Scene<'a> {
}
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = vectorcraft_doc::live::expand_deep(n, None);
let g = vectorcraft_effects::expand_live_deep(n);
for c in g.children().into_iter().flatten() {
self.node(c, false);
}
+2
View File
@@ -34,6 +34,7 @@ mod bake;
mod clip;
mod distort;
mod group;
mod live;
mod marks;
mod raster;
mod reshape;
@@ -58,6 +59,7 @@ pub use group::{
OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, paints,
pathfinder_children,
};
pub use live::{expand_live, expand_live_deep, text_outliner};
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use raster::{RasterFx, outset, raster_effects};
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
+25
View File
@@ -0,0 +1,25 @@
//! Live objects (blends, envelopes, gradient meshes, repeats) evaluated alike for the renderer and
//! every exporter, with what the pure evaluation in [`vectorcraft_doc::live`] can't do itself: type
//! inside an envelope distorts as its glyph outlines, run by run ([`outline_text`]).
use vectorcraft_doc::Node;
use vectorcraft_doc::live::{self, Outliner};
use crate::outline_text;
/// The text outliner live evaluation uses: type as its glyph outlines, one path per run in the
/// run's paint ([`outline_text`]).
pub fn text_outliner() -> Outliner<'static> {
Some(&outline_text)
}
/// One level of evaluation of live object `n` ([`live::expand_live`]) with type outlined.
pub fn expand_live(n: &Node) -> Vec<Node> {
live::expand_live_with(n, text_outliner())
}
/// `n` with every live object in it replaced by its evaluated art ([`live::expand_deep`]) and type
/// inside envelopes outlined: what the exporters write for live objects.
pub fn expand_live_deep(n: &Node) -> Node {
live::expand_deep(n, text_outliner())
}
+1 -2
View File
@@ -826,8 +826,7 @@ fn env_options(s: &mut Session, p: &Value) -> Result<Value> {
/// transparency and opacity mask; the generated pieces get ids of their own.
fn expand_envelope(d: &mut Document, id: NodeId) -> Result<()> {
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let hook: &dyn Fn(&Node) -> Option<Node> = &vectorcraft_render::effects::outline_text;
let mut g = live::expanded_group(&n, Some(hook));
let mut g = live::expanded_group(&n, vectorcraft_render::effects::text_outliner());
fix_ids(d, &mut g);
// Outlined type repeats its object's id on its pieces.
vectorcraft_render::effects::fresh_ids(d, &mut g, &mut Default::default());
+57
View File
@@ -209,3 +209,60 @@ fn envelope_options_round_trip_natively_and_old_files_load() {
}
assert!(serde_json::to_value(&legacy).unwrap()["kind"].get("options").is_none());
}
/// Regression: every exporter wrote type inside an envelope undistorted (no text outliner), so a
/// warped line of type exported the same whatever its bend.
#[test]
fn enveloped_type_exports_distorted_in_every_format() {
let mut s = session();
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Envelope", "size": 48})).unwrap());
sel(&mut s, &[t]);
s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 0})).unwrap();
let export =
|s: &mut Session, format: &str| s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].as_str().unwrap().to_string();
for format in ["svg", "pdf", "eps", "emf", "dxf"] {
s.execute("object.envelope.options", &json!({"bend": 0})).unwrap();
let flat = export(&mut s, format);
assert_eq!(export(&mut s, format), flat, "{format} exports are repeatable");
s.execute("object.envelope.options", &json!({"bend": 80})).unwrap();
assert_ne!(export(&mut s, format), flat, "{format}: the bend shows in the export");
}
let svg = String::from_utf8(vectorcraft_format::base64_decode(&export(&mut s, "svg")).unwrap()).unwrap();
assert!(!svg.contains("<text"), "type in an envelope exports as its distorted outlines");
}
/// Regression: on the canvas, type in an envelope was outlined in its first run's paint only.
#[test]
fn enveloped_type_keeps_each_runs_colour_on_the_canvas() {
let mut s = session();
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "MMMMMMMM", "size": 60, "fill": "#000000"})).unwrap());
s.edit("red half", |d, _| {
if let NodeKind::Text(tx) = &mut d.node_mut(t).unwrap().kind {
vectorcraft_text::edit::style_range(&mut tx.runs, 0, 4, |st| {
st.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))
});
}
Ok(())
})
.unwrap();
sel(&mut s, &[t]);
s.execute("object.envelope.makeWithWarp", &json!({"style": "flag", "bend": 0})).unwrap();
let b = node(&s, t).geometric_bounds().unwrap();
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 800.0, 400.0), 1.0, true);
// Inked pixels of each half of the line: red at the start, black at the end.
let (mut red, mut black) = (0, 0);
for y in (b.y0 as u32)..(b.y1 as u32) {
for x in (b.x0 as u32)..(b.x1 as u32) {
let p = img.pixel(x, y);
let left = (x as f64) < b.center().x;
if p[0] > 200 && p[1] < 60 {
assert!(left, "red at ({x}, {y})");
red += 1;
} else if p[0] < 60 && p[1] < 60 && p[3] > 200 {
assert!(!left, "black at ({x}, {y})");
black += 1;
}
}
}
assert!(red > 100 && black > 100, "{red} red, {black} black");
}
+1 -1
View File
@@ -292,7 +292,7 @@ impl<'a> Scene<'a> {
}
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = vectorcraft_doc::live::expand_deep(n, None);
let g = vectorcraft_effects::expand_live_deep(n);
for c in g.children().into_iter().flatten() {
self.node(c, false);
}
+1 -1
View File
@@ -190,7 +190,7 @@ impl<'a> Scene<'a> {
}
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = vectorcraft_doc::live::expand_deep(n, None);
let g = vectorcraft_effects::expand_live_deep(n);
for c in g.children().into_iter().flatten() {
self.node(c, false);
}
+1 -1
View File
@@ -983,7 +983,7 @@ impl Exporter<'_> {
}
// Live blends/envelopes/meshes export their evaluated (expanded) form.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = vectorcraft_doc::live::expand_deep(n, None);
let g = vectorcraft_effects::expand_live_deep(n);
for c in g.children().into_iter().flatten() {
self.node(s, c, page, false);
}
+7 -29
View File
@@ -10,13 +10,13 @@
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_doc::live::{self, GradientMesh, MeshQuad};
use vectorcraft_doc::live::{GradientMesh, MeshQuad};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{BezPath, PathData, Vec2};
use vectorcraft_geom::{BezPath, Vec2};
use vello_cpu::RenderContext;
use vello_cpu::peniko;
use crate::{Frame, Renderer, text_geom};
use crate::{Frame, Renderer};
type Expanded = Arc<Vec<Arc<Node>>>;
type MeshEntry = (Arc<Node>, Arc<Vec<MeshQuad>>, u64);
@@ -41,30 +41,9 @@ impl LiveCache {
}
}
/// Text converted to one outline path (for envelopes): glyphs in document space, painted with the
/// first run's fill/stroke.
pub(crate) fn outline_text(n: &Node) -> Option<Node> {
let NodeKind::Text(t) = &n.kind else { return None };
let g = text_geom(t);
let mut bp = g.all.clone();
bp.apply_affine(t.xf);
let mut out = Node::path(n.id, PathData::from_bezpath(&bp), n.appearance.clone());
if out.appearance.items.is_empty()
&& let Some(r) = t.runs.first()
{
out.appearance = r.style.appearance();
}
out.opacity = n.opacity;
out.blend = n.blend;
Some(out)
}
/// Evaluate a live node one level (blend steps / envelope result, text outlined; mesh → flat
/// pieces). Non-live nodes return themselves.
pub fn expand_live(n: &Node) -> Vec<Node> {
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
live::expand_live_with(n, Some(hook))
}
/// One level of evaluation of a live node (blend steps, envelope result with type outlined run by
/// run, mesh → flat pieces); the exporters evaluate them alike. Non-live nodes return themselves.
pub use crate::effects::expand_live;
/// Subdivisions per patch for a patch about `size_px` device pixels across.
fn mesh_level(m: &GradientMesh, bounds_px: f64) -> usize {
@@ -82,8 +61,7 @@ impl Renderer {
e.2 = self.live.stamp;
return e.1.clone();
}
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
let v: Expanded = match crate::effects::pathfinder_children(a, Some(hook)) {
let v: Expanded = match crate::effects::pathfinder_children(a, crate::effects::text_outliner()) {
Some(children) => Arc::new(children),
None => Arc::new(expand_live(a).into_iter().map(Arc::new).collect()),
};
+1 -1
View File
@@ -1290,7 +1290,7 @@ impl Writer<'_> {
}
// Live blends/envelopes/meshes export their evaluated (expanded) form.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = vectorcraft_doc::live::expand_deep(n, None);
let g = vectorcraft_effects::expand_live_deep(n);
self.node_body(&g);
}
}
+2
View File
@@ -1785,6 +1785,8 @@ envelopes, the path for a top-object envelope); the content keeps the envelope's
(a group around it carries them when there are several objects). `object.envelope.expand`, and `object.expand` with
`object` on, replace envelopes by groups of their distorted content that keep the name, transparency, knockout and
opacity mask.
Type inside an envelope distorts as its glyph outlines, run by run in each run's paint, on the canvas and in every
export (SVG, PDF, EPS, EMF/WMF, DXF).
```json
{"name":"run_command","arguments":{"command":"object.envelope.makeWithWarp","params":{"style":"arch","bend":40}}}