Merge pull request #184 from storytold/m8-envelope-text-export

M8.16: Enveloped type exports distorted, run by run, everywhere
This commit is contained in:
BFlat
2026-10-06 10:27:18 +07:00
committed by GitHub
12 changed files with 100 additions and 37 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options) | 8–12 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted) | 8–12 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
+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}}}