Merge pull request #5966 from dotCooCoo/fix/ai-envelope-stroked-box

.ai import: envelopes map from their stroked box when the file saves its size
This commit is contained in:
BFlat
2026-11-07 09:08:45 +08:00
committed by GitHub
14 changed files with 459 additions and 25 deletions
+60 -2
View File
@@ -53,8 +53,8 @@ pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation,
#[cfg(test)]
use blend_spec::{blend_color_shift, blend_ramp};
pub use envelope::{
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_expand, envelope_frame,
envelope_grid, envelope_mapper, envelope_of, envelope_overlay, smooth_handles,
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_content_boxes,
envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin, smooth_handles,
};
pub(crate) use expand::max_outset;
pub use expand::{
@@ -233,6 +233,64 @@ mod tests {
assert_eq!(envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 100.0))(p), p);
}
/// `bounds_margin` grows the box an envelope maps from, only while Distort Appearance is on. It
/// holds shares of the box, and an envelope with an uneven frame maps from the same box in that
/// frame. A native file writes it only when it isn't zero.
#[test]
fn an_envelope_maps_from_its_box_with_its_margins() {
let rect = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), crate::Appearance::default_art());
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true };
let content = [Arc::new(rect)];
let src = |content: &[Arc<Node>], frame: Affine, options: EnvelopeOptions| EnvelopeMap::new(content, &kind, frame, options).unwrap().src();
// Top, bottom, left and right.
let on = EnvelopeOptions { bounds_margin: [0.125, 0.25, 0.0625, 0.5], ..EnvelopeOptions::NEW };
let geometric = Rect::new(10.0, 20.0, 110.0, 70.0);
let grown = Rect::new(3.75, 13.75, 160.0, 82.5);
assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions::default()), geometric);
assert_eq!(src(&content, Affine::IDENTITY, on), grown);
assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions { distort_appearance: false, ..on }), geometric);
for bad in [f32::NAN, f32::INFINITY, -0.05] {
assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions { bounds_margin: [bad, 0.25, 0.0625, 0.5], ..on }), geometric);
}
// A finite share of a box too wide to grow leaves the box as it is.
let wide = Node::path(NodeId(3), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 1e308, 10.0)), crate::Appearance::default_art());
let wide = [Arc::new(wide)];
let huge = EnvelopeOptions { bounds_margin: [0.0, 0.0, f32::MAX, 0.0], ..on };
assert_eq!(src(&wide, Affine::IDENTITY, huge), src(&wide, Affine::IDENTITY, EnvelopeOptions::NEW));
// Bounds follow the box: a warp that doesn't bend covers it.
let b = envelope_bounds(&content, &kind, Affine::IDENTITY, on).unwrap();
assert!((b.x0 - 3.75).abs() < 1e-6 && (b.y1 - 82.5).abs() < 1e-6, "{b:?}");
// Art taken through an uneven frame maps from the same box in that frame.
let frame = Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale_non_uniform(2.0, 0.5);
let mut placed = (*content[0]).clone();
placed.transform(frame, true);
let framed = src(&[Arc::new(placed)], frame, on);
assert!((framed.x0 - 3.75).abs() + (framed.y0 - 13.75).abs() + (framed.x1 - 160.0).abs() + (framed.y1 - 82.5).abs() < 1e-6, "{framed:?}");
// A frame that turns and scales gives the same margins as the art had before it took them.
let mut stroked = (*content[0]).clone();
if let Some(s) = stroked.appearance.stroke_mut() {
s.width = 10.0;
}
let margin = |n: &Node, frame: Affine| {
let (with, plain) = envelope_content_boxes(&[Arc::new(n.clone())], frame).unwrap();
envelope_stroke_margin(with, plain).unwrap()
};
let before = margin(&stroked, Affine::IDENTITY);
assert_eq!(before.map(|m| (m * 1000.0).round()), [100.0, 100.0, 50.0, 50.0]);
let frame = Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale(2.0);
let mut turned = stroked.clone();
turned.transform(frame, true);
assert_eq!(margin(&turned, frame).map(|m| (m * 1000.0).round()), before.map(|m| (m * 1000.0).round()));
// Margins an `f32` can't hold are none.
assert_eq!(envelope_stroke_margin(Rect::new(-5e41, 0.0, 100.0, 100.0), Rect::new(0.0, 0.0, 100.0, 100.0)), None);
assert!(serde_json::to_value(EnvelopeOptions::default()).unwrap().get("boundsMargin").is_none());
let saved = serde_json::to_value(on).unwrap();
assert_eq!(saved["boundsMargin"], serde_json::json!([0.125, 0.25, 0.0625, 0.5]));
assert_eq!(serde_json::from_value::<EnvelopeOptions>(saved).unwrap(), on);
}
/// How far the border of `m` strays from the ellipse in `r`, in points (measured across the
/// ellipse's short radius), checked at 20 places along every border edge.
fn mesh_border_off_ellipse(m: &GradientMesh, r: Rect) -> f64 {
+55 -7
View File
@@ -76,6 +76,12 @@ pub struct EnvelopeOptions {
pub distort_linear_gradients: bool,
/// Distort Pattern Fills (with Distort Appearance): pattern tiles bend with the art.
pub distort_pattern_fills: bool,
/// With Distort Appearance, how far the box the content maps from reaches past its geometric
/// bounds at the top, bottom, left and right, as shares of their height and width. An envelope
/// opened from a `.ai` whose saved size includes its strokes has these margins. New envelopes
/// have none. Leaving Edit Contents measures them again when the strokes reach differently.
#[serde(skip_serializing_if = "crate::skip::is_default")]
pub bounds_margin: [f32; 4],
}
impl Default for EnvelopeOptions {
@@ -86,6 +92,7 @@ impl Default for EnvelopeOptions {
distort_appearance: false,
distort_linear_gradients: false,
distort_pattern_fills: false,
bounds_margin: [0.0; 4],
}
}
}
@@ -98,6 +105,7 @@ impl EnvelopeOptions {
distort_appearance: true,
distort_linear_gradients: false,
distort_pattern_fills: false,
bounds_margin: [0.0; 4],
};
/// Do gradients bend with the art?
pub fn gradients(&self) -> bool {
@@ -107,6 +115,45 @@ impl EnvelopeOptions {
pub fn patterns(&self) -> bool {
self.distort_appearance && self.distort_pattern_fills
}
/// The box content with geometric bounds `r` maps from. Margins that aren't finite, are
/// negative or give a box that isn't finite count as none.
fn source_box(&self, r: Rect) -> Rect {
let [top, bottom, left, right] = self.bounds_margin.map(f64::from);
if !self.distort_appearance || !self.bounds_margin.iter().all(|m| m.is_finite() && *m >= 0.0) {
return r;
}
let grown = crate::marks::outset(r, [top * r.height(), bottom * r.height(), left * r.width(), right * r.width()]);
if grown.is_finite() { grown } else { r }
}
}
/// The bounds of `content` in the axes of `frame`, with its strokes and geometric. Strokes scale
/// with the frame as [`Node::transform`] scales them.
pub fn envelope_content_boxes(content: &[Arc<Node>], frame: Affine) -> Option<(Rect, Rect)> {
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
let to_frame = frame.inverse();
let (stroked, plain) = content.iter().fold((None, None), |(stroked, plain), c| {
let mut m = (**c).clone();
m.transform(to_frame, true);
(vectorcraft_geom::union_opt(stroked, m.visual_bounds()), vectorcraft_geom::union_opt(plain, m.geometric_bounds()))
});
Some((stroked?, plain?))
}
/// The [`EnvelopeOptions::bounds_margin`] of content with bounds `stroked` and `plain`. `None` for
/// content with no width or no height, or for margins an `f32` can't hold.
pub fn envelope_stroke_margin(stroked: Rect, plain: Rect) -> Option<[f32; 4]> {
if plain.width() < 1e-6 || plain.height() < 1e-6 {
return None;
}
let margin = [
(plain.y0 - stroked.y0) / plain.height(),
(stroked.y1 - plain.y1) / plain.height(),
(plain.x0 - stroked.x0) / plain.width(),
(stroked.x1 - plain.x1) / plain.width(),
]
.map(|m| m as f32);
margin.iter().all(|m| m.is_finite()).then_some(margin)
}
/// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row.
@@ -218,9 +265,9 @@ pub struct EnvelopeMap<'a> {
}
impl<'a> EnvelopeMap<'a> {
/// The map of an envelope of `kind` around `content` with `frame`. `None` when the content
/// has no bounds.
pub fn new(content: &[Arc<Node>], kind: &'a EnvelopeKind, frame: Affine) -> Option<Self> {
/// The map of an envelope of `kind` around `content` with `frame` and `options`. `None` when
/// the content has no bounds.
pub fn new(content: &[Arc<Node>], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option<Self> {
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
let to_frame = frame.inverse();
let src = if frame == Affine::IDENTITY {
@@ -232,13 +279,14 @@ impl<'a> EnvelopeMap<'a> {
Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds()))
})??
};
let src = options.source_box(src);
Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) })
}
/// The map of envelope node `n` (`None` for other nodes).
pub fn of(n: &'a Node) -> Option<Self> {
match &n.kind {
NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame),
NodeKind::Envelope { content, kind, frame, options, .. } => Self::new(content, kind, **frame, *options),
_ => None,
}
}
@@ -302,7 +350,7 @@ pub(super) fn expand_envelope(
frame: Affine,
hooks: Hooks,
) -> Vec<Node> {
let Some(map) = EnvelopeMap::new(content, kind, frame) else { return content.iter().map(|c| (**c).clone()).collect() };
let Some(map) = EnvelopeMap::new(content, kind, frame, options) else { return content.iter().map(|c| (**c).clone()).collect() };
let src = map.src();
let fid = if fidelity.is_finite() { fidelity.clamp(0.0, 100.0) / 100.0 } else { 0.5 };
let f = |p: Point| map.map(p);
@@ -319,7 +367,7 @@ pub(super) fn expand_envelope(
}
/// Envelope bounds: a mesh's points, a top object's path, the warp's surface (sampled).
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine) -> Option<Rect> {
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option<Rect> {
match kind {
EnvelopeKind::Mesh { rows, cols, points, handles } if !handles.is_empty() => envelope_grid(*rows, *cols, points, handles)?.outline().bounds(),
EnvelopeKind::Mesh { points, .. } => {
@@ -327,6 +375,6 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)))
}
EnvelopeKind::TopObject { path } => path.bounds(),
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame)?.bounds(),
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame, options)?.bounds(),
}
}
+4
View File
@@ -66,6 +66,9 @@ pub struct NodeExtras {
/// As an instance of a dynamic symbol, its own appearance changes to objects of the symbol's
/// art (see [`crate::symbol_overrides`]); empty for none.
pub symbol_overrides: Vec<crate::SymbolOverride>,
/// As an envelope with margins in Edit Contents, the margins its strokes gave when Edit Contents
/// began ([`crate::live::EnvelopeOptions::bounds_margin`]).
pub envelope_edit_margin: Option<[f32; 4]>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -91,6 +94,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
compound_shape: false,
shape_mode: crate::ShapeMode::Add,
symbol_overrides: Vec::new(),
envelope_edit_margin: None,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
+1 -1
View File
@@ -300,7 +300,7 @@ impl Node {
self.projected(Some(xf.transform_rect_bbox(art)))
}
NodeKind::Blend { children, spec } => vectorcraft_geom::union_opt(union(children), spec.spine.as_ref().and_then(|s| s.bounds())),
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame),
NodeKind::Envelope { content, kind, frame, options, .. } => crate::live::envelope_bounds(content, kind, **frame, *options),
NodeKind::Mesh(m) => m.bounds(),
// Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the
// copies out by the source's bounds measured without the document.
+2
View File
@@ -21,6 +21,7 @@ fn extras_are_written_flat_in_order() {
e.live_paint_gaps = Some(Box::default());
e.compound_shape = true;
e.shape_mode = crate::ShapeMode::Exclude;
e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]);
}
/// The top-level keys of a JSON object, in the order written.
struct Keys(Vec<String>);
@@ -65,6 +66,7 @@ fn extras_are_written_flat_in_order() {
"live_paint_gaps",
"compound_shape",
"shape_mode",
"envelope_edit_margin",
];
assert_eq!(keys, order);
assert_eq!(v["shape_mode"], "exclude");
+5
View File
@@ -53,6 +53,7 @@ impl Serialize for Node {
is_default(&e.compound_shape),
is_default(&e.shape_mode),
e.symbol_overrides.is_empty(),
e.envelope_edit_margin.is_none(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -94,6 +95,7 @@ impl Serialize for Node {
field!("compound_shape", &e.compound_shape);
field!("shape_mode", &e.shape_mode);
field!("symbol_overrides", &e.symbol_overrides);
field!("envelope_edit_margin", &e.envelope_edit_margin);
st.end()
}
}
@@ -232,6 +234,8 @@ pub(super) struct NodeWire {
shape_mode: crate::ShapeMode,
#[serde(default, deserialize_with = "counted_list")]
symbol_overrides: Vec<crate::SymbolOverride>,
#[serde(default)]
envelope_edit_margin: Option<[f32; 4]>,
}
impl From<NodeWire> for Node {
@@ -258,6 +262,7 @@ impl From<NodeWire> for Node {
compound_shape: w.compound_shape,
shape_mode: w.shape_mode,
symbol_overrides: w.symbol_overrides,
envelope_edit_margin: w.envelope_edit_margin,
};
Node {
id: w.id,
+3 -3
View File
@@ -181,7 +181,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Envelope Info",
[],
None,
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, editing}",
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}",
has_doc,
env_info
),
@@ -199,7 +199,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Envelope Options…",
["Object", "Envelope Distort"],
None,
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
has_doc,
env_options
),
@@ -217,7 +217,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Contents",
["Object", "Envelope Distort"],
None,
"{editing?: bool} toggle Edit Contents / Edit Envelope → {editing}",
"{editing?: bool} toggle Edit Contents / Edit Envelope; leaving Edit Contents measures an envelope's boundsMargin again when the content's strokes reach differently than when Edit Contents began → {editing}",
has_envelope,
env_edit_contents
),
+27 -3
View File
@@ -177,6 +177,9 @@ fn envelope_options_json(mut v: Value, o: &EnvelopeOptions, fidelity: f64) -> Va
v["distortAppearance"] = json!(o.distort_appearance);
v["distortLinearGradients"] = json!(o.distort_linear_gradients);
v["distortPatternFills"] = json!(o.distort_pattern_fills);
if o.bounds_margin != [0.0; 4] {
v["boundsMargin"] = json!(o.bounds_margin);
}
v
}
@@ -192,6 +195,7 @@ fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result<EnvelopeO
distort_appearance: switch_or(p, "distortAppearance", cur.distort_appearance, cmd)?,
distort_linear_gradients: switch_or(p, "distortLinearGradients", cur.distort_linear_gradients, cmd)?,
distort_pattern_fills: switch_or(p, "distortPatternFills", cur.distort_pattern_fills, cmd)?,
bounds_margin: cur.bounds_margin,
})
}
@@ -369,6 +373,12 @@ pub(super) fn env_expand(s: &mut Session, _: &Value) -> Result<Value> {
Ok(ids_json(&ids))
}
/// The margins the strokes of `content` give in the axes of `frame` (none for content without
/// width or height).
fn stroke_margin(content: &[Arc<Node>], frame: Affine) -> [f32; 4] {
live::envelope_content_boxes(content, frame).and_then(|(stroked, plain)| live::envelope_stroke_margin(stroked, plain)).unwrap_or_default()
}
pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.editContents";
let envs = selected_of(s, is_envelope);
@@ -377,12 +387,26 @@ pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
s.edit(if want { "Edit Contents" } else { "Edit Envelope" }, |d, sel| {
let mut content_ids = vec![];
for e in &envs {
if let Some(n) = d.node_mut(*e)
&& let NodeKind::Envelope { editing, content, .. } = &mut n.kind
{
let Some(n) = d.node_mut(*e) else { continue };
let start = n.extras().envelope_edit_margin;
let mut began = None;
if let NodeKind::Envelope { editing, content, options, frame, .. } = &mut n.kind {
// Margins stay when the strokes reach as far as when Edit Contents began, whatever
// transforms the envelope took before that.
if options.bounds_margin != [0.0; 4] {
let now = stroke_margin(content, **frame);
if want {
began = start.filter(|_| *editing).or(Some(now));
} else if *editing && start != Some(now) {
options.bounds_margin = now;
}
}
*editing = want;
content_ids.extend(content.iter().map(|c| c.id));
}
if began != start {
n.extras_mut().envelope_edit_margin = began;
}
}
if want {
sel.set(content_ids);
+125
View File
@@ -221,6 +221,131 @@ fn envelope_options_round_trip_natively_and_old_files_load() {
assert!(serde_json::to_value(&legacy).unwrap()["kind"].get("options").is_none());
}
/// Envelope Options change the options they name: the margins an envelope opens with from a file
/// stay, and they survive a native save.
#[test]
fn envelope_options_keep_the_margins_of_an_opened_file() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}));
assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]);
let margin = [0.01, 0.02, 0.01, 0.02];
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
if let Some(NodeKind::Envelope { options, .. }) = doc.node_mut(e).map(|n| &mut n.kind) {
options.bounds_margin = margin;
}
s.execute("object.envelope.options", &json!({"antiAlias": false, "fidelity": 75})).unwrap();
assert_eq!(options(&s, e).0.bounds_margin, margin);
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
assert_eq!(back.node(e), d.node(e));
assert_eq!(s.execute("object.envelope.info", &json!({})).unwrap()["boundsMargin"], json!(margin));
s.execute("object.envelope.options", &json!({"distortAppearance": false})).unwrap();
assert_eq!(options(&s, e).0.bounds_margin, margin);
}
/// `object.envelope.info` leaves out margins that are zero.
#[test]
fn envelope_info_reports_margins_only_when_it_has_them() {
let mut s = session();
enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}));
assert!(s.execute("object.envelope.info", &json!({})).unwrap().get("boundsMargin").is_none());
}
/// Leaving Edit Contents measures the margins again when a stroke changed: a wider stroke gives
/// larger ones and no stroke gives none, after which a stroke adds none, as in a new envelope. A
/// new envelope's margins stay zero.
#[test]
fn leaving_edit_contents_measures_the_margins_again() {
let mut s = session();
let (a, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}));
let set_stroke = |s: &mut Session, weight: f64| {
sel(s, &[e]);
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
sel(s, &[a]);
s.execute("stroke.set", &json!({"weight": weight})).unwrap();
sel(s, &[e]);
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
};
set_stroke(&mut s, 10.0);
assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]);
let margin = [0.05, 0.05, 0.025, 0.025];
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
if let Some(NodeKind::Envelope { options, .. }) = doc.node_mut(e).map(|n| &mut n.kind) {
options.bounds_margin = [0.5; 4];
}
set_stroke(&mut s, 20.0);
assert_eq!(options(&s, e).0.bounds_margin, margin.map(|m| m * 2.0));
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
assert_eq!(options(&s, e).0.bounds_margin, margin.map(|m| m * 2.0));
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
assert_eq!(options(&s, e).0.bounds_margin, margin.map(|m| m * 2.0));
set_stroke(&mut s, 10.0);
assert_eq!(options(&s, e).0.bounds_margin, margin);
// A stroke change, then a second entry, undone back to the first: leaving measures the change.
sel(&mut s, &[e]);
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
sel(&mut s, &[a]);
s.execute("stroke.set", &json!({"weight": 30.0})).unwrap();
sel(&mut s, &[e]);
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
sel(&mut s, &[e]);
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
assert_eq!(options(&s, e).0.bounds_margin, margin.map(|m| m * 3.0));
set_stroke(&mut s, 0.0);
assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]);
set_stroke(&mut s, 10.0);
assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]);
}
/// After an uneven scale, Edit Contents without a stroke change leaves the margins and the box the
/// content maps from, and a stroke change measures them.
#[test]
fn edit_contents_keeps_the_margins_of_a_scaled_envelope_until_a_stroke_changes() {
let mut s = session();
let (a, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}));
sel(&mut s, &[a]);
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
s.execute("stroke.set", &json!({"weight": 10.0})).unwrap();
sel(&mut s, &[e]);
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
let margin = [0.05, 0.05, 0.025, 0.025];
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
if let Some(NodeKind::Envelope { options, .. }) = doc.node_mut(e).map(|n| &mut n.kind) {
options.bounds_margin = margin;
}
s.execute("object.transform", &json!({"ids": [e.0], "matrix": [2, 0, 0, 0.5, 0, 0]})).unwrap();
let src = |s: &Session| live::EnvelopeMap::of(s.doc().unwrap().doc.node(e).unwrap()).unwrap().src();
let box_before = src(&s);
sel(&mut s, &[e]);
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
assert_eq!(options(&s, e).0.bounds_margin, margin);
assert_eq!(src(&s), box_before);
// Undoing that step and leaving again gives the same.
s.execute("edit.undo", &json!({})).unwrap();
sel(&mut s, &[e]);
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
assert_eq!(options(&s, e).0.bounds_margin, margin);
// So does saving natively in Edit Contents, reopening and leaving.
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(back.node(e), s.doc().unwrap().doc.node(e));
assert!(back.node(e).unwrap().extras().envelope_edit_margin.is_some());
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
assert!(node(&s, e).extras().envelope_edit_margin.is_none());
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
sel(&mut s, &[a]);
s.execute("stroke.set", &json!({"weight": 0.0})).unwrap();
sel(&mut s, &[e]);
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
assert_eq!(options(&s, e).0.bounds_margin, [0.0; 4]);
}
/// 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]
+5 -2
View File
@@ -3219,6 +3219,9 @@ proptest! {
fidelity in prop::sample::select(vec!["50", "0", "100", "101", "-1", "1e308", "nan"]),
size in prop::sample::select(vec!["[1 1]", "[3 2]", "[0 0]", "[51 1]", "[100000 100000]", "[-1 2]", "[1e308 1]"]),
corner in prop::sample::select(vec!["10 10", "1e308 -1e308", "nan 0", "10 10 10 10", "0"]),
art_width in prop::sample::select(vec!["81", "80", "0", "-5", "1e308", "nan"]),
art_height in prop::sample::select(vec!["81", "80", "0", "-5", "1e308", "nan"]),
weight in prop::sample::select(vec!["1", "0", "1e308", "-1"]),
edit in 0i64..2,
cut in 0usize..2_000,
edits in prop::collection::vec((0usize..2_000, prop::sample::select(vec!['0', '9', '-', ' ', '\n', '(', ')', '/', ':', ';', ',', '[', ']', '%', '_', 'X', 'u', 'U'])), 0..8),
@@ -3231,9 +3234,9 @@ proptest! {
n("10", "90"),
n("90", "90")
);
let content = "%_u\n%_1 0 0 1 0 0 Xa\n%_10 10 m\n%_10 90 L\n%_90 90 L\n%_90 10 L\n%_f\n%_U\n";
let content = format!("%_u\n%_1 0 0 1 0 0 Xa\n%_0 1 1 0 1 0 0 XA\n%_{weight} w\n%_10 10 m\n%_10 90 L\n%_90 90 L\n%_90 10 L\n%_b\n%_U\n");
let keys = format!(
"%_{word} /Int (AIDeformOptionsKey) ,\n%_{fidelity} /Int (AIDeformFidelityKey) ,\n%_{edit} /Bool (AIDeformEditModeKey) ,\n%_{style} /Int (DeformStyle) ,\n%_0.5 /Real (DeformValue) ,\n%_1 /Bool (Rotate) ,\n"
"%_{word} /Int (AIDeformOptionsKey) ,\n%_{fidelity} /Int (AIDeformFidelityKey) ,\n%_{edit} /Bool (AIDeformEditModeKey) ,\n%_{style} /Int (DeformStyle) ,\n%_0.5 /Real (DeformValue) ,\n%_1 /Bool (Rotate) ,\n%_{art_width} /Real (AIDeformArtWidthKey) ,\n%_{art_height} /Real (AIDeformArtHeightKey) ,\n"
);
let art = format!(
"%_u\n{mesh}%_U\n%_/ArtDictionary :\n{keys}%_X=\n{content}%_X+\n%_ (AIDeformArtKey) ,\n%_{kind} /Int (AIDeformStyle) ,\n%_;\n%_\nu\n1 0 0 1 0 0 Xa\n12 12 m\n12 88 L\n88 88 L\nf\nU\n(Adobe Deform Plugin) 1 0 0 XP\n0 0 1 1 0 0 Xa\n0 0 m\n5 5 L\nf\n"
+34 -4
View File
@@ -14,7 +14,9 @@
//! then its `/ArtDictionary`: Envelope Options as a bit word (`AIDeformOptionsKey`), Fidelity
//! (`AIDeformFidelityKey`), Edit Contents (`AIDeformEditModeKey`), a warp's style (`DeformStyle`,
//! numbered as Effect › Warp lists them), bend and distortions (`DeformValue`, `DeformHoriz`,
//! `DeformVert`, fractions) and orientation (`Rotate`), the contents (`X=` … `X+`, then
//! `DeformVert`, fractions) and orientation (`Rotate`), the size of the contents, read as the size
//! of the box they map from with Distort Appearance on when it matches their bounds with strokes
//! (`AIDeformArtWidthKey`, `AIDeformArtHeightKey`), the contents (`X=` … `X+`, then
//! `(AIDeformArtKey)`) and the kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object). Then the
//! distorted art on plain lines, and the plugin's `XP`. While the contents are edited, the group
//! holds the contents and the dictionary the mesh. A top object's outline is the edge of its
@@ -24,8 +26,11 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use kurbo::{BezPath, Point, Rect, Vec2};
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, default_fidelity};
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
use vectorcraft_doc::live::{
EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, default_fidelity, envelope_content_boxes,
envelope_stroke_margin,
};
use vectorcraft_doc::{Document, ImageBlob, LayerColor, Node, NodeId, NodeKind, PatternDef};
use vectorcraft_geom::PathData;
@@ -42,6 +47,9 @@ pub(super) const MAX_PENDING: usize = 64;
pub(super) const MAX_CHECKED: usize = 256;
/// The most rows or columns of a mesh read (Make with Mesh's own limit).
const MAX_GRID: u32 = 50;
/// How far, in points over width and height together, a saved size may lie from the contents'
/// bounds with their strokes and still count as that box.
const SAVED_SIZE_SLACK: f64 = 0.5;
/// The bits of the options word that are read: Distort Appearance, Distort Pattern Fills, Distort
/// Linear Gradients, Anti-Alias (set: off) and Preserve Shape Using (set: Clipping Mask).
const KNOWN_BITS: i64 = 0b111 | 1 << 16 | 1 << 17;
@@ -88,10 +96,29 @@ pub(super) fn options(o: &Obj) -> Option<(EnvelopeOptions, f64)> {
distort_appearance: bit(0),
distort_pattern_fills: bit(1),
distort_linear_gradients: bit(2),
bounds_margin: [0.0; 4],
};
Some((options, fidelity))
}
/// The margins of `content` when the size the file saves for it (`AIDeformArtWidthKey`,
/// `AIDeformArtHeightKey`) matches its bounds with its strokes within [`SAVED_SIZE_SLACK`] and lies
/// nearer them than its geometric bounds. None without that size, or for contents with no width or
/// no height.
fn saved_margin(o: &Obj, content: &[Arc<Node>]) -> Option<[f32; 4]> {
let saved = |key: &str| match o.nums(key).as_slice() {
[v] if v.is_finite() => Some(*v),
_ => None,
};
let (w, h) = (saved("AIDeformArtWidthKey")?, saved("AIDeformArtHeightKey")?);
let (stroked, plain) = envelope_content_boxes(content, Affine::IDENTITY)?;
let off = |r: Rect| (r.width() - w).abs() + (r.height() - h).abs();
if off(stroked) > SAVED_SIZE_SLACK || off(stroked) >= off(plain) {
return None;
}
envelope_stroke_margin(stroked, plain)
}
/// A warp: its style, bend, distortions and orientation.
fn warp(o: &Obj) -> Option<EnvelopeKind> {
let style = whole(o, "DeformStyle")??;
@@ -232,7 +259,7 @@ fn unwrapped(mut nodes: Vec<Node>) -> Vec<Arc<Node>> {
/// in its `AIDeformArtKey` block (`inner`); `None` where they don't make one.
pub(super) fn live(o: &Obj, before: Option<Node>, inner: Vec<Node>) -> Option<NodeKind> {
let editing = switch(o, "AIDeformEditModeKey")?;
let (options, fidelity) = options(o)?;
let (mut options, fidelity) = options(o)?;
let before = before?;
// While the contents are edited, they are the group before the dictionary and the mesh is in it.
let (content, shape): (Vec<Arc<Node>>, Option<&GradientMesh>) = if editing {
@@ -249,6 +276,9 @@ pub(super) fn live(o: &Obj, before: Option<Node>, inner: Vec<Node>) -> Option<No
if content.is_empty() {
return None;
}
if options.distort_appearance {
options.bounds_margin = saved_margin(o, &content).unwrap_or_default();
}
let kind = match whole(o, "AIDeformStyle")?? {
1 => warp(o)?,
2 => {
+130 -2
View File
@@ -5883,8 +5883,14 @@ pub(super) fn envelope_content() -> String {
/// side, mapped as VectorCraft maps them, as a red path in art space.
pub(super) fn drawn_content(kind: &vectorcraft_doc::live::EnvelopeKind) -> String {
let rect = Rect::new(40.0, 20.0, 160.0, 80.0);
let map = vectorcraft_doc::live::envelope_mapper(kind, rect);
let corners = [Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(160.0, 80.0), Point::new(40.0, 80.0)];
drawn_box(kind, rect, rect)
}
/// What envelope `kind`, mapping from box `src`, draws of a red rectangle `rect`, as in
/// [`drawn_content`].
fn drawn_box(kind: &vectorcraft_doc::live::EnvelopeKind, src: Rect, rect: Rect) -> String {
let map = vectorcraft_doc::live::envelope_mapper(kind, src);
let corners = [Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)];
let mut s = RED.to_string();
for (k, a) in corners.iter().enumerate() {
let b = corners[(k + 1) % 4];
@@ -6090,6 +6096,7 @@ fn an_envelope_reads_its_options_and_fidelity() {
distort_appearance: false,
distort_linear_gradients: false,
distort_pattern_fills: false,
bounds_margin: [0.0; 4],
},
),
(
@@ -6101,6 +6108,7 @@ fn an_envelope_reads_its_options_and_fidelity() {
distort_appearance: true,
distort_linear_gradients: true,
distort_pattern_fills: true,
bounds_margin: [0.0; 4],
},
),
(
@@ -6112,6 +6120,7 @@ fn an_envelope_reads_its_options_and_fidelity() {
distort_appearance: false,
distort_linear_gradients: false,
distort_pattern_fills: true,
bounds_margin: [0.0; 4],
},
),
] {
@@ -6124,6 +6133,125 @@ fn an_envelope_reads_its_options_and_fidelity() {
}
}
/// The red rectangle 40 20 160 80 with a red stroke 10 pt wide, its art 130 × 70 with the stroke.
fn stroked_content() -> String {
format!("{RED}0 1 1 0 1 0 0 XA\n10 w\n{}b\n", rect_path(40.0, 20.0, 160.0, 80.0))
}
/// A one-patch mesh over the stroked art of [`stroked_content`], and that patch as a flat grid.
fn stroked_shape() -> (String, Vec<Point>) {
let at = |(i, j): (usize, usize)| (35.0 + i as f64 * 130.0, 15.0 + j as f64 * 70.0);
let third = |p: (usize, usize), q: (usize, usize)| {
let ((x, y), (u, v)) = (at(p), at(q));
(x + (u - x) / 3.0, y + (v - y) / 3.0)
};
(mesh_text(1, 1, &at, &third, None), vec![Point::new(35.0, 15.0), Point::new(165.0, 15.0), Point::new(35.0, 85.0), Point::new(165.0, 85.0)])
}
/// An envelope whose file saves the size of its art with the stroke maps from that box, in a
/// synthetic mesh and top object. Their drawn art is the box mapped onto the shape, and the
/// geometric box would leave the stroke 5 pt past it.
#[test]
fn an_envelope_saved_with_its_stroke_maps_from_the_stroked_box() {
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeMap};
let (mesh, points) = stroked_shape();
let keys = format!("{ENVELOPE_KEYS}70 /Real (AIDeformArtHeightKey) ,\n130 /Real (AIDeformArtWidthKey) ,\n");
let outer = Rect::new(35.0, 15.0, 165.0, 85.0);
let flat = EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] };
let drawn = drawn_box(&flat, outer, outer);
for kind in [2, 3] {
let r = read_ok(&layer(&envelope_of(&mesh, &keys, &stroked_content(), kind, &drawn)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(r.warnings.is_empty(), "kind {kind}: {:?}", r.warnings);
let (.., options) = live_envelope(&a[0]);
assert_eq!(options.bounds_margin, [5.0_f32 / 60.0, 5.0 / 60.0, 5.0 / 120.0, 5.0 / 120.0]);
let src = EnvelopeMap::of(&a[0]).unwrap().src();
assert!(
(src.x0 - 35.0).abs() < 1e-3 && (src.y0 - 15.0).abs() < 1e-3 && (src.x1 - 165.0).abs() < 1e-3 && (src.y1 - 85.0).abs() < 1e-3,
"{src:?}"
);
}
}
/// With Distort Appearance off the stroke is drawn after the distortion: the envelope maps from the
/// geometric box whatever size its file saves.
#[test]
fn an_envelope_without_distort_appearance_maps_from_the_geometric_box() {
use vectorcraft_doc::live::EnvelopeMap;
let keys = "65536 /Int (AIDeformOptionsKey) ,\n50 /Int (AIDeformFidelityKey) ,\n0 /Bool (AIDeformEditModeKey) ,\n70 /Real (AIDeformArtHeightKey) ,\n130 /Real (AIDeformArtWidthKey) ,\n";
let outer = Rect::new(35.0, 15.0, 165.0, 85.0);
let flat = vectorcraft_doc::live::EnvelopeKind::Mesh {
rows: 1,
cols: 1,
points: vec![Point::new(35.0, 15.0), Point::new(165.0, 15.0), Point::new(35.0, 85.0), Point::new(165.0, 85.0)],
handles: vec![],
};
let drawn = drawn_box(&flat, outer, outer);
let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), keys, &stroked_content(), 2, &drawn)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(!live_envelope(&a[0]).4.distort_appearance);
assert_eq!(live_envelope(&a[0]).4.bounds_margin, [0.0; 4]);
let src = EnvelopeMap::of(&a[0]).unwrap().src();
assert!((src.width() - 120.0).abs() < 1e-6 && (src.height() - 60.0).abs() < 1e-6, "{src:?}");
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
/// An envelope maps from the geometric box when its file saves that box's size, saves no size,
/// saves a size that isn't a number or saves a size far from both boxes; contents without a stroke
/// have one box, and contents with no height have no margins.
#[test]
fn an_envelope_not_saved_with_its_stroke_maps_from_the_geometric_box() {
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeMap};
let (mesh, points) = stroked_shape();
let inner = Rect::new(40.0, 20.0, 160.0, 80.0);
let flat = EnvelopeKind::Mesh { rows: 1, cols: 1, points, handles: vec![] };
// The stroked art mapped from the geometric box: it reaches past the mesh by the stroke's share.
let drawn = drawn_box(&flat, inner, Rect::new(35.0, 15.0, 165.0, 85.0));
let sizes = [
"60 /Real (AIDeformArtHeightKey) ,\n120 /Real (AIDeformArtWidthKey) ,\n",
"",
"(tall) /UnicodeString (AIDeformArtHeightKey) ,\n(wide) /UnicodeString (AIDeformArtWidthKey) ,\n",
"70 /Real (AIDeformArtHeightKey) ,\n",
"1000 /Real (AIDeformArtHeightKey) ,\n1000 /Real (AIDeformArtWidthKey) ,\n",
"71 /Real (AIDeformArtHeightKey) ,\n131 /Real (AIDeformArtWidthKey) ,\n",
];
for size in sizes {
let keys = format!("{ENVELOPE_KEYS}{size}");
let r = read_ok(&layer(&envelope_of(&mesh, &keys, &stroked_content(), 2, &drawn)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{size}: {a:#?}");
let (.., options) = live_envelope(&a[0]);
assert_eq!(options.bounds_margin, [0.0; 4], "{size}");
let src = EnvelopeMap::of(&a[0]).unwrap().src();
assert!((src.width() - 120.0).abs() < 1e-6 && (src.height() - 60.0).abs() < 1e-6, "{size}: {src:?}");
}
let keys = format!("{ENVELOPE_KEYS}60 /Real (AIDeformArtHeightKey) ,\n120 /Real (AIDeformArtWidthKey) ,\n");
let plain = drawn_content(&EnvelopeKind::Mesh {
rows: 1,
cols: 1,
points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)],
handles: vec![],
});
let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &envelope_content(), 2, &plain)));
let a = art(&r.doc);
assert_eq!(live_envelope(&a[0]).4.bounds_margin, [0.0; 4]);
// A stroke so thin that the saved geometric size also lies close to the stroked box.
let thin = format!("{RED}0 1 1 0 1 0 0 XA\n0.2 w\n{}b\n", rect_path(40.0, 20.0, 160.0, 80.0));
let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &thin, 2, &plain)));
assert_eq!(live_envelope(&art(&r.doc)[0]).4.bounds_margin, [0.0; 4]);
// A stroked straight line: 120 × 0 without its stroke, and 120 × 10 as saved.
let keys = format!("{ENVELOPE_KEYS}10 /Real (AIDeformArtHeightKey) ,\n120 /Real (AIDeformArtWidthKey) ,\n");
let line = format!("{RED}0 1 1 0 1 0 0 XA\n10 w\n40 20 m\n160 20 L\nS\n");
let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &line, 2, &plain)));
for n in art(&r.doc) {
if let NodeKind::Envelope { options, .. } = &n.kind {
assert_eq!(options.bounds_margin, [0.0; 4]);
}
}
}
/// An envelope's contents keep their groups: a group of two rectangles stays that group.
#[test]
fn an_envelope_s_contents_keep_their_groups() {
+7
View File
@@ -6132,6 +6132,13 @@ them with `image-rendering: pixelated` and PDF export writes them without `/Inte
of a clip group, after Expand too. New envelopes start with Distort Appearance on, as in the reference app; envelopes saved before these
options existed keep their old look (appearance applied after the distortion).
An envelope opened from a `.ai` can have margins, written to native files as `boundsMargin` and reported by
`object.envelope.info` when they are not zero: `[top, bottom, left, right]`, as shares of the content's height and
width. While `distortAppearance` is on, the content maps from its geometric bounds grown by the margins, and while it
is off, the content maps from its geometric bounds. Leaving Edit Contents measures the margins again when the content's
strokes reach differently than when Edit Contents began, and always for an envelope whose file opened in Edit Contents.
New envelopes have no margins, and no command sets them.
`object.envelope.release` gives back the content and the envelope's shape (a grey gradient mesh for warp and mesh
envelopes, the path for a top-object envelope); the content keeps the envelope's opacity, blend mode and opacity mask
(a group around it carries them when there are several objects). `object.envelope.expand`, and `object.expand` with
+1 -1
View File
@@ -1231,7 +1231,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`).
A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`).
A `.ai` or EPS opened from its editing data with one object on a shown layer that the reader can't read (an unknown operator inside a group, a pattern fill whose pattern it doesn't have, a symbol instance, a placed file without a usable preview) keeps its layers and groups: in that object's place, its layer gets a group of the page's objects inside the object's box, named after it, with a warning that lists what came in as the page's art and an export or Save As to its own path asking first (`acknowledgeLoss`). The file still opens from its page or PDF part when no box can be found, when live art or another unread object shares the page art inside it, when the object spans two artboards, or when the page art doesn't carry the object's transparency (#4806; `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`).
In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points.
Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392).