From 42be9283675d3bcd35fe8d55127e1aca4cc8ae7a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 10:10:01 +0700 Subject: [PATCH] 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 --- crates/cad/src/scene.rs | 2 +- crates/effects/src/lib.rs | 2 + crates/effects/src/live.rs | 25 +++++++++++++ crates/engine/src/cmd/live.rs | 3 +- crates/engine/src/tests_envelope.rs | 57 +++++++++++++++++++++++++++++ crates/eps/src/scene.rs | 2 +- crates/metafile/src/scene.rs | 2 +- crates/pdf/src/export.rs | 2 +- crates/render/src/live.rs | 36 ++++-------------- crates/svg/src/export.rs | 2 +- docs/mcp.md | 2 + 11 files changed, 99 insertions(+), 36 deletions(-) create mode 100644 crates/effects/src/live.rs diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 11d15e43f..69dd14631 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -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); } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 303ab40b8..97272f7eb 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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}; diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs new file mode 100644 index 000000000..c2f0f8a2f --- /dev/null +++ b/crates/effects/src/live.rs @@ -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 { + 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()) +} diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index f3d05c87f..fa52015bc 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -826,8 +826,7 @@ fn env_options(s: &mut Session, p: &Value) -> Result { /// 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 = &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()); diff --git a/crates/engine/src/tests_envelope.rs b/crates/engine/src/tests_envelope.rs index 54e000f99..42ac631ad 100644 --- a/crates/engine/src/tests_envelope.rs +++ b/crates/engine/src/tests_envelope.rs @@ -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(" 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"); +} diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index bef20136b..50c8dbd60 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -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); } diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 10077ad04..649e6c422 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -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); } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 955270e97..268a877d4 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -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); } diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 1af0de424..e1918f5c6 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -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>>; type MeshEntry = (Arc, Arc>, 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 { - 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 { - let hook: &dyn Fn(&Node) -> Option = &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 = &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()), }; diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 44c8c498b..c380750ee 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -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); } } diff --git a/docs/mcp.md b/docs/mcp.md index dedf38a17..b3d12fe99 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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}}}