From 8f3f6a82d7df3b66d5aa328e0e0ef6d1f5ce557a Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 20:44:39 +0000 Subject: [PATCH 1/7] .ai import: a warp of type opens as a mesh envelope on its saved grid A warp of type whose own warp doesn't draw as its drawn art is checked again as a mesh envelope on the grid the editing data saves for it, its points and handles as saved. Where that draws as the drawn art, it opens live in its place: the type stays editable in that shape, and the warp's style and bend are lost. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/envelope.rs | 77 ++++++++++----- crates/eps/src/import/tests_native.rs | 137 +++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 24 deletions(-) diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index c0da27f2a..fe3ba51f4 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -20,8 +20,9 @@ //! `(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 -//! one-patch mesh. An envelope whose parts don't read, or that doesn't come out as drawn, keeps its -//! drawn art. +//! one-patch mesh. A warp's mesh is a grid of its shape: a warp of type whose own warp doesn't come +//! out as drawn opens as a mesh envelope on that grid where that does. An envelope whose parts don't +//! read, or that doesn't come out as drawn, keeps its drawn art. use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; @@ -269,6 +270,23 @@ fn unwrapped(mut nodes: Vec) -> Vec> { /// The live envelope of dictionary `o`, the group on `%_` lines before it (`before`) and the art /// in its `AIDeformArtKey` block (`inner`); `None` where they don't make one. pub(super) fn live(o: &Obj, before: Option, inner: Vec) -> Option { + envelope_as(o, before, inner, false) +} + +/// Is the envelope of dictionary `o` a warp? +fn is_warp(o: &Obj) -> bool { + whole(o, "AIDeformStyle") == Some(Some(1)) +} + +/// [`live`] for a warp, as a mesh envelope on the grid its editing data saves (the mesh that shapes +/// it), its points and handles as saved: it draws as that grid does, without the warp's style, bend +/// and distortions. `None` for an envelope that isn't a warp, or whose grid doesn't read. +fn live_on_saved_grid(o: &Obj, before: Option, inner: Vec) -> Option { + envelope_as(o, before, inner, true) +} + +/// [`live`], or with `on_grid` [`live_on_saved_grid`]. +fn envelope_as(o: &Obj, before: Option, inner: Vec, on_grid: bool) -> Option { let editing = switch(o, "AIDeformEditModeKey")?; let (mut options, fidelity) = options(o)?; let before = before?; @@ -290,14 +308,14 @@ pub(super) fn live(o: &Obj, before: Option, inner: Vec) -> Option warp(o)?, - 2 => { + let kind = match (whole(o, "AIDeformStyle")??, on_grid) { + (1, false) => warp(o)?, + (1, true) | (2, false) => { let (rows, cols, pts) = upright(shape?)?; let (points, handles) = pts.into_iter().unzip(); EnvelopeKind::Mesh { rows, cols, points, handles } } - 3 => { + (3, false) => { let (rows, cols, pts) = upright(shape?)?; // The outline runs clockwise from the top left corner, one segment per grid edge. let corners = [0, cols, cols.checked_add(rows)?, cols.checked_mul(2)?.checked_add(rows)?]; @@ -448,10 +466,13 @@ impl WarpedType { /// Make each envelope of type live where it draws as its drawn art, its contents' text slots /// holding their type: `type_of` gives, for a slot's id, the hidden text object it became in /// `doc` and the type the contents take in its place (the slot's visibility and lock are the - /// type's). The live envelope takes its drawn art's place and the hidden type goes. Each check - /// uses one of the import's checks and, for an envelope that shows, adds to what the page check - /// allows ([`drawn_alike`]). Elsewhere, and where the type is set in a font that isn't - /// available, the drawn art and the hidden type stay. + /// type's). The live envelope takes its drawn art's place and the hidden type goes. A warp that + /// doesn't draw as its drawn art is checked again as a mesh envelope on the grid the file saves + /// for it ([`live_on_saved_grid`]), which opens in its place where it does: its type stays + /// editable in that shape, and the warp's style and bend are lost. Each check uses one of the + /// import's checks and, for an envelope that shows, adds to what the page check allows + /// ([`drawn_alike`]). Elsewhere, and where the type is set in a font that isn't available, the + /// drawn art and the hidden type stay. pub(crate) fn settle_envelopes(&mut self, doc: &mut Document, type_of: impl Fn(&Document, NodeId) -> Option<(NodeId, Node)>) -> TypedSettled { let mut settled = TypedSettled::default(); if self.envelopes.is_empty() { @@ -503,22 +524,34 @@ impl WarpedType { for n in before.iter_mut().chain(inner.iter_mut()) { put_type(n, &texts); } - let kind = live(&obj, before, inner).ok_or(TypeKept::Read)?; - if self.checks == 0 { - return Err(TypeKept::Budget); + // A warp that VectorCraft's own warp doesn't draw as its drawn art may still draw as the + // grid the file saves for it: then it opens as a mesh envelope on that grid. + let on_grid = if is_warp(&obj) { live_on_saved_grid(&obj, before.clone(), inner.clone()) } else { None }; + let kinds: Vec = live(&obj, before, inner).into_iter().chain(on_grid).collect(); + if kinds.is_empty() { + return Err(TypeKept::Read); } - self.checks -= 1; - let mut envelope = art.clone(); - (envelope.id, envelope.kind) = (doc.alloc_id(), kind); - let layer = doc.alloc_id(); - let shared = Shared { patterns: &doc.patterns, images: &doc.images }; - let (mut live_one, mut plain) = (envelope.clone(), art.clone()); - (live_one.visible, live_one.opacity) = (true, 1.0); + let (id, layer) = (doc.alloc_id(), doc.alloc_id()); + let mut plain = art.clone(); (plain.visible, plain.opacity) = (true, 1.0); let page = self.page.filter(|_| shown.contains(&drawn)); - if !drawn_alike(&live_one, &plain, layer, &shared, page, &mut self.spent) { - return Err(TypeKept::Differs); + let mut alike = None; + for kind in kinds { + if self.checks == 0 { + return Err(TypeKept::Budget); + } + self.checks -= 1; + let mut envelope = art.clone(); + (envelope.id, envelope.kind) = (id, kind); + let shared = Shared { patterns: &doc.patterns, images: &doc.images }; + let mut live_one = envelope.clone(); + (live_one.visible, live_one.opacity) = (true, 1.0); + if drawn_alike(&live_one, &plain, layer, &shared, page, &mut self.spent) { + alike = Some(envelope); + break; + } } + let envelope = alike.ok_or(TypeKept::Differs)?; if doc.node(drawn).is_none() { return Err(TypeKept::Read); } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 867d36d1a..e1b4f68e8 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5346,7 +5346,6 @@ fn type_envelope(keys: &str, drawn: &str) -> String { /// [`type_envelope`], with `after` (on plain lines, written on `%_` ones) after the text object in /// its contents, such as the text object's XML id. fn type_envelope_with(keys: &str, drawn: &str, after: &str) -> String { - let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); let mesh = format!( "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n", @@ -5355,9 +5354,16 @@ fn type_envelope_with(keys: &str, drawn: &str, after: &str) -> String { corner(80.0, 70.0, 80.0, 60.0, 70.0, 70.0), corner(80.0, 40.0, 70.0, 40.0, 80.0, 50.0), ); + type_envelope_on(keys, &mesh, drawn, after) +} + +/// [`type_envelope_with`], with `shape` (on plain lines, written on `%_` ones) in the group before +/// its dictionary, where the editing data saves the envelope's mesh. +fn type_envelope_on(keys: &str, shape: &str, drawn: &str, after: &str) -> String { + let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; format!( "{}{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_1 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n", - hidden(&format!("u\n{mesh}U\n")), + hidden(&format!("u\n{shape}U\n")), hidden(&format!("/ArtDictionary :\n{keys}")), hidden(&format!("u\n{}{after}U\n9 () XW\n", text_object(0))), ) @@ -5654,6 +5660,133 @@ fn an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art() { assert!(!r.warnings.iter().any(|n| n.contains("variable bindings")), "{:?}", r.warnings); } +/// A one-patch warp grid as the editing data saves it, which no warp style draws: its top edge +/// rises to the right and bulges up. In art space, corners (20, 40) (20, 73) (80, 61) (80, 40), each +/// with its handles towards the next corner and the previous one. +fn rising_grid() -> String { + let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); + format!( + "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n", + corner(20.0, 40.0, 20.0, 51.0, 40.0, 40.0), + corner(20.0, 73.0, 40.0, 80.0, 20.0, 62.0), + corner(80.0, 61.0, 80.0, 54.0, 60.0, 72.0), + corner(80.0, 40.0, 60.0, 40.0, 80.0, 47.0), + ) +} + +/// [`rising_grid`] on the page axes as a mesh envelope reads it: its points from the top left, row +/// by row, and their handles (towards the next column, the previous one, the next row and the +/// previous one; none out of the grid). +fn rising_kind() -> vectorcraft_doc::live::EnvelopeKind { + use kurbo::{Point, Vec2}; + let z = Vec2::ZERO; + vectorcraft_doc::live::EnvelopeKind::Mesh { + rows: 1, + cols: 1, + points: vec![Point::new(20.0, 27.0), Point::new(80.0, 39.0), Point::new(20.0, 60.0), Point::new(80.0, 60.0)], + handles: vec![ + [Vec2::new(20.0, -7.0), z, Vec2::new(0.0, 11.0), z], + [z, Vec2::new(-20.0, -11.0), Vec2::new(0.0, 7.0), z], + [Vec2::new(20.0, 0.0), z, z, Vec2::new(0.0, -11.0)], + [z, Vec2::new(-20.0, 0.0), z, Vec2::new(0.0, -7.0)], + ], + } +} + +/// The anchors of the paths `n` draws once its live art is expanded. +fn drawn_anchors(n: &Node) -> Vec { + fn walk(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Path { path, .. } => out.extend(path.subpaths.iter().flat_map(|s| s.anchors.iter().map(|a| a.p))), + _ => n.children().into_iter().flatten().for_each(|c| walk(c, out)), + } + } + let mut out = vec![]; + walk(&vectorcraft_effects::expand_live_deep(None, n), &mut out); + out +} + +/// A warp of type whose own warp draws apart from its drawn art, where the grid the file saves for +/// it draws as that art, opens as a mesh envelope on that grid, its points and handles as saved: +/// the type is editable, and new text fills the same shape (#3179). +#[test] +fn a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes() { + use vectorcraft_doc::live::EnvelopeKind; + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let mut text = made_envelope_type(&doc); + text.visible = true; + let options = vectorcraft_doc::live::EnvelopeOptions::NEW; + let shaped = |text: Node| { + let content = vec![std::sync::Arc::new(text)]; + let kind = Box::new(rising_kind()); + Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { content, kind, fidelity: 50.0, editing: false, options, frame: Box::default() }, + ) + }; + let (drawn, page) = drawn_envelope(&shaped(text)); + let art = + native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope_on(&arc_keys(0.3), &rising_grid(), &drawn, ""))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings); + assert!(loose.is_empty(), "{loose:?}"); + assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{:?}", r.warnings); + let mut live = None; + r.document.walk(|n| { + if matches!(n.kind, NodeKind::Envelope { .. }) { + live = Some(n.clone()); + } + }); + let mut live = live.unwrap(); + let NodeKind::Envelope { kind, content, .. } = &mut live.kind else { panic!("not an envelope: {live:?}") }; + let (EnvelopeKind::Mesh { rows, cols, points, handles }, EnvelopeKind::Mesh { points: want, handles: want_h, .. }) = (&**kind, rising_kind()) + else { + panic!("not a mesh envelope: {kind:?}") + }; + assert_eq!((*rows, *cols), (1, 1)); + assert!(points.iter().zip(&want).all(|(p, q)| (*p - *q).hypot() < 1e-6), "{points:?}"); + assert!(handles.iter().flatten().zip(want_h.iter().flatten()).all(|(h, g)| (*h - *g).hypot() < 1e-6), "{handles:?}"); + // New text in the envelope fills the grid as the old did: "HIH", whose corners are its glyphs', + // reaches each corner of the grid. + let NodeKind::Text(t) = &mut std::sync::Arc::make_mut(&mut content[0]).kind else { panic!("type") }; + let style = t.runs[0].style.clone(); + t.runs = vec![vectorcraft_doc::TextRun::new("HIH", style)]; + (t.cached_bounds, t.cached_ink) = (None, None); + t.cached_baselines.clear(); + let anchors = drawn_anchors(&live); + for corner in want { + let near = anchors.iter().map(|a| (*a - corner).hypot()).fold(f64::INFINITY, f64::min); + assert!(near < 0.25, "{corner:?} is {near} from the new text"); + } +} + +/// A warp of type whose editing data saves no grid it can be shaped by keeps its drawn art and its +/// type, hidden, with the note, when its own warp draws apart from that art (#3179). +#[test] +fn a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let mut text = made_envelope_type(&doc); + text.visible = true; + let content = vec![std::sync::Arc::new(text)]; + let options = vectorcraft_doc::live::EnvelopeOptions::NEW; + let shaped = Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { content, kind: Box::new(rising_kind()), fidelity: 50.0, editing: false, options, frame: Box::default() }, + ); + let (drawn, page) = drawn_envelope(&shaped); + let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope_on(&arc_keys(0.3), "", &drawn, ""))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings); + assert_eq!(super::tests::program_warnings(&r.warnings), [crate::import::ai::ENVELOPE_TYPE_DRAWN], "{:?}", r.warnings); +} + /// The fill of the first run of type `n`. fn text_fill(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None }; From 7504a0e2e45889545159a7257acb010895ff5b2b Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 19:01:54 +0000 Subject: [PATCH 2/7] .ai import: an envelope mesh bent far round reads the way it runs An envelope's mesh is turned upright by which way its rows and columns run. That was read from the first row alone, so a warp bent past halfway (its end columns leaning more than 45 degrees) read as turned round and mapped its contents across instead of along. Read it from the first and last rows and columns added up instead. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/envelope.rs | 20 +++++++++--- crates/eps/src/import/ai/tests.rs | 46 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index fe3ba51f4..3a45bf3a1 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -189,9 +189,19 @@ fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { let mut pts: Vec = m.points.iter().map(|q| (q.p, q.handles)).collect(); let at = |pts: &[GridPoint], cols: u32, r: u32, c: u32| pts.get((r * (cols + 1) + c) as usize).map_or(Point::ZERO, |q| q.0); let remap = |h: [Vec2; 4], slots: [usize; 4]| slots.map(|s| h.get(s).copied().unwrap_or(Vec2::ZERO)); - // Columns that run down the page: the grid goes the other way round. - let along = at(&pts, cols, 0, cols) - at(&pts, cols, 0, 0); - if along.y.abs() > along.x.abs() { + // How the grid runs along its rows (its first and last rows, end to end added up) and down its + // columns (its first and last columns): read from both sides, a grid bent far round (its end + // columns leaning more than halfway over) still reads the way it runs. + let runs = |pts: &[GridPoint], rows: u32, cols: u32| { + let along = (at(pts, cols, 0, cols) - at(pts, cols, 0, 0)) + (at(pts, cols, rows, cols) - at(pts, cols, rows, 0)); + let down = (at(pts, cols, rows, 0) - at(pts, cols, 0, 0)) + (at(pts, cols, rows, cols) - at(pts, cols, 0, cols)); + (along, down) + }; + // How much of a run is across the page. + let level = |v: Vec2| if v.hypot() > 0.0 { v.x.abs() / v.hypot() } else { 0.0 }; + // Columns that run across the page more than rows do: the grid goes the other way round. + let (along, down) = runs(&pts, rows, cols); + if level(down) > level(along) { let mut t = Vec::with_capacity(len); for r in 0..=cols { for c in 0..=rows { @@ -205,7 +215,7 @@ fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { (rows, cols) = (cols, rows); pts = t; } - if (at(&pts, cols, 0, cols) - at(&pts, cols, 0, 0)).x < 0.0 { + if runs(&pts, rows, cols).0.x < 0.0 { for row in pts.chunks_mut(cols as usize + 1) { row.reverse(); for (_, h) in row.iter_mut() { @@ -213,7 +223,7 @@ fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { } } } - if (at(&pts, cols, rows, 0) - at(&pts, cols, 0, 0)).y < 0.0 { + if runs(&pts, rows, cols).1.y < 0.0 { let mut flipped: Vec = pts.chunks(cols as usize + 1).rev().flatten().copied().collect(); for (_, h) in &mut flipped { h.swap(H_DOWN, H_UP); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 2b127e275..039f8e1a8 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7501,6 +7501,52 @@ fn a_mesh_envelope_opens_live_with_its_points_and_handles() { assert!(r.warnings.is_empty(), "{:?}", r.warnings); } +/// A mesh envelope bent far round, its end columns leaning more than halfway over (a warp's grid +/// bent into an arc), opens live the way it runs: its columns along the arc, as in the file. +#[test] +fn a_mesh_envelope_bent_far_round_opens_the_way_it_runs() { + use vectorcraft_doc::live::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP}; + // 3 columns along an arc round (100, 0) from 160 to 20 degrees, rows at radius 60 and 90: the + // end columns lean 70 degrees over. + let at = |(i, j): (usize, usize)| { + let (a, r) = ((160.0 - i as f64 * 140.0 / 3.0).to_radians(), 60.0 + j as f64 * 30.0); + (100.0 + r * a.cos(), r * a.sin()) + }; + let handle = |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) + }; + // Written with its first index across the arc and its second along it, so that the grid reads + // turned round and has to be turned upright. + let shape = mesh_text(1, 3, &|(i, j)| at((j, i)), &|p, q| handle((p.1, p.0), (q.1, q.0)), None); + // Row r of the envelope is row 1 - r of the file (art space has y up). + let doc_pt = |(x, y): (f64, f64)| Point::new(x, 100.0 - y); + let (mut points, mut handles) = (vec![], vec![]); + for r in 0..=1usize { + for c in 0..=3usize { + let g = (c, 1 - r); + let p = doc_pt(at(g)); + points.push(p); + let towards = |q: Option<(usize, usize)>| q.filter(|q| q.0 <= 3 && q.1 <= 1).map_or(kurbo::Vec2::ZERO, |q| doc_pt(handle(g, q)) - p); + let mut h = [kurbo::Vec2::ZERO; 4]; + h[H_RIGHT] = towards(Some((g.0 + 1, g.1))); + h[H_LEFT] = towards(g.0.checked_sub(1).map(|i| (i, g.1))); + h[H_UP] = towards(Some((g.0, g.1 + 1))); + h[H_DOWN] = towards(g.1.checked_sub(1).map(|j| (g.0, j))); + handles.push(h); + } + } + let want = EnvelopeKind::Mesh { rows: 1, cols: 3, points, handles }; + let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &envelope_content(), 2, &drawn_content(&want)))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let EnvelopeKind::Mesh { rows, cols, points: got, .. } = live_envelope(&a[0]).1 else { panic!("{a:#?}") }; + assert_eq!((*rows, *cols), (1, 3)); + let EnvelopeKind::Mesh { points: want, .. } = &want else { panic!("{want:?}") }; + assert!(got.iter().zip(want).all(|(p, q)| (*p - *q).hypot() < 1e-6), "{got:?}"); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + /// An envelope made with a top object opens as a live envelope whose top path is the edge of its /// one-patch mesh, an anchor on the edge included; its contents stay the rectangle. #[test] From 37e3c0215ac0b037da902259e3f94243aa2152e2 Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 19:31:45 +0000 Subject: [PATCH 3/7] .ai import: type in an envelope may map from its bounds with strokes With Distort Appearance, an envelope's contents map from the box the file measured for them: their bounds with their strokes or without them. For type, the size the file saves for that box is often out of date, so it can't say which. Envelopes of type are now checked both ways: as before, and mapping from their bounds with their strokes. Whichever draws as the drawn art opens live. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/envelope.rs | 40 +++++++++++++++++++++++---- crates/eps/src/import/tests_native.rs | 39 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 3a45bf3a1..461be66fc 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -131,6 +131,25 @@ fn saved_margin(o: &Obj, content: &[Arc]) -> Option<[f32; 4]> { envelope_stroke_margin(stroked, plain) } +/// Envelope `kind` mapping from its contents' bounds with their strokes, for an envelope with +/// Distort Appearance whose contents have strokes it doesn't map from already; `None` otherwise. +fn with_strokes(kind: &NodeKind) -> Option { + let NodeKind::Envelope { content, options, .. } = kind else { return None }; + if !options.distort_appearance { + return None; + } + let (stroked, plain) = envelope_content_boxes(content, Affine::IDENTITY)?; + let margin = envelope_stroke_margin(stroked, plain)?; + if margin == options.bounds_margin || margin.iter().any(|m| *m < 0.0) || margin.iter().all(|m| *m == 0.0) { + return None; + } + let mut kind = kind.clone(); + if let NodeKind::Envelope { options, .. } = &mut kind { + options.bounds_margin = margin; + } + Some(kind) +} + /// A warp: its style, bend, distortions and orientation. fn warp(o: &Obj) -> Option { let style = whole(o, "DeformStyle")??; @@ -479,10 +498,11 @@ impl WarpedType { /// type's). The live envelope takes its drawn art's place and the hidden type goes. A warp that /// doesn't draw as its drawn art is checked again as a mesh envelope on the grid the file saves /// for it ([`live_on_saved_grid`]), which opens in its place where it does: its type stays - /// editable in that shape, and the warp's style and bend are lost. Each check uses one of the - /// import's checks and, for an envelope that shows, adds to what the page check allows - /// ([`drawn_alike`]). Elsewhere, and where the type is set in a font that isn't available, the - /// drawn art and the hidden type stay. + /// editable in that shape, and the warp's style and bend are lost. With Distort Appearance, each + /// is also checked mapping from its contents' bounds with their strokes ([`with_strokes`]). Each + /// check uses one of the import's checks and, for an envelope that shows, adds to what the page + /// check allows ([`drawn_alike`]). Elsewhere, and where the type is set in a font that isn't + /// available, the drawn art and the hidden type stay. pub(crate) fn settle_envelopes(&mut self, doc: &mut Document, type_of: impl Fn(&Document, NodeId) -> Option<(NodeId, Node)>) -> TypedSettled { let mut settled = TypedSettled::default(); if self.envelopes.is_empty() { @@ -537,7 +557,17 @@ impl WarpedType { // A warp that VectorCraft's own warp doesn't draw as its drawn art may still draw as the // grid the file saves for it: then it opens as a mesh envelope on that grid. let on_grid = if is_warp(&obj) { live_on_saved_grid(&obj, before.clone(), inner.clone()) } else { None }; - let kinds: Vec = live(&obj, before, inner).into_iter().chain(on_grid).collect(); + // With Distort Appearance, a file's envelope may map its contents from their bounds with + // their strokes or without them, and for type the size the file saves for that box is often + // out of date (not the size of the type there now): each is checked. + let kinds: Vec = live(&obj, before, inner) + .into_iter() + .chain(on_grid) + .flat_map(|k| { + let stroked = with_strokes(&k); + std::iter::once(k).chain(stroked) + }) + .collect(); if kinds.is_empty() { return Err(TypeKept::Read); } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index e1b4f68e8..e8ad5a3f5 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5763,6 +5763,45 @@ fn a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its } } +/// A warp of stroked type with Distort Appearance whose drawn art maps the type's bounds with its +/// strokes onto the grid the file saves (not its glyphs' bounds alone) opens live as a mesh envelope +/// mapping from that box (#3179). +#[test] +fn a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does() { + use vectorcraft_doc::live::{envelope_content_boxes, envelope_stroke_margin}; + let Some(family) = installed_family() else { return }; + // "FLUX" stroked 4 points wide in black. + let stroked = "/53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> /54 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 0.0 0.0 1.0 ] >> >> /57 true /63 4.0"; + let doc = type_envelope_text(&family, None).replace("/53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >>", stroked); + let mut text = made_envelope_type(&doc); + text.visible = true; + assert!(matches!(&text.kind, NodeKind::Text(t) if t.runs[0].style.stroke_width == 4.0), "{text:?}"); + let content = vec![std::sync::Arc::new(text)]; + let (with, without) = envelope_content_boxes(&content, kurbo::Affine::IDENTITY).unwrap(); + let mut options = vectorcraft_doc::live::EnvelopeOptions::NEW; + options.bounds_margin = envelope_stroke_margin(with, without).unwrap(); + assert!(options.bounds_margin.iter().all(|m| *m > 0.0), "{options:?}"); + let shaped = Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { content, kind: Box::new(rising_kind()), fidelity: 50.0, editing: false, options, frame: Box::default() }, + ); + let (drawn, page) = drawn_envelope(&shaped); + let art = + native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope_on(&arc_keys(0.3), &rising_grid(), &drawn, ""))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings); + assert!(loose.is_empty(), "{loose:?}"); + let mut margin = None; + r.document.walk(|n| { + if let NodeKind::Envelope { options, .. } = &n.kind { + margin = Some(options.bounds_margin); + } + }); + assert_eq!(margin, Some(options.bounds_margin)); +} + /// A warp of type whose editing data saves no grid it can be shaped by keeps its drawn art and its /// type, hidden, with the note, when its own warp draws apart from that art (#3179). #[test] From 8820a15e20d38342b609ea73c17d81d7bd692b3e Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 20:45:05 +0000 Subject: [PATCH 4/7] docs: warps of type as mesh envelopes on their saved grid Co-Authored-By: Claude Opus 5.5 --- docs/file-format-parity.md | 3 ++- docs/target-app-parity.md | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 06fac1069..0d110ad45 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. Type in an envelope with Distort Appearance on and strokes is also tried mapped from its box with strokes. A mesh bent far round (its end columns leaning more than halfway over) now reads the way it runs, not turned a quarter round. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs` (part of #3179) | | 2026-11-11 | minor | `.ai` and EPS import: type stroked in the text document keeps its stroke's cap, join and miter limit (keys 61, 62 and 64 of its character attributes, numbered as PostScript's `setlinecap` and `setlinejoin`: 0 butt or miter, 1 round, 2 projecting or bevel). They were left at a miter join with a limit of 10 whatever the file said, so type the file strokes with round joins, or with the usual limit of 4 or less, drew spikes at its sharp corners, some many points long. In users' files, used locally and never committed, the page strokes the type with the cap, join and miter limit these keys give; no file had a bevel join, which is read by the same numbering. A value that isn't one of theirs keeps the default, a miter limit past 500 is 500, and type without a stroke keeps the defaults. Dashes are not read: no file had dashed type to tell which keys hold them. Tests `ate::tests::a_run_keeps_its_stroke_cap_join_and_miter_limit`, `tests_native::stroked_type_keeps_its_cap_join_and_miter_limit`; scores unchanged | | 2026-11-10 | minor | `.ai` and EPS import: an envelope of type (a warp, a mesh or a top object around type) kept its drawn art, its type hidden, because the type is laid out from the text document only after the layers are read. It now waits for that: its text slots get their type, and where VectorCraft's own envelope of that type draws as the drawn art the file saves (the same check and budget as other envelopes, seen on white and on black), it opens live in the drawn art's place, its type editable in Edit Contents, and the hidden copy goes. As with other live envelopes, the layers with one are then compared with the page, which weighs the envelope as the art it draws (not as type laid out again, which the comparison allows more for), and when they don't draw as it the drawn art and the hidden type are put back. Layers used though they don't draw as the page (the editing data trusted, or the objects that differ coming in as the page's art) leave envelopes of type their drawn art and hidden type too. A `.ai` whose layers would open alone, without its PDF part, imports the PDF part to compare them with, and should they still not draw as it, or should no envelope of type open live, they open alone as before, with the envelopes' drawn art. Type in a font that isn't available, slots without type of their own (threaded, bound to a text or a visibility variable, a wrap object, type a look draws), envelopes in a symbol's or a pattern's art, and envelopes that draw differently keep their drawn art, with the note (#3179). Tests `tests_native::an_envelope_of_type_that_draws_as_its_drawn_art_opens_live`, `an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone`, `an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live`, `trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art`, `layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art`, `an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art` | | 2026-11-10 | minor | `.ai` save: a variable font's record in the text document's font set names the type's place in the font's design space (`/4`, each `fvar` axis as 16.16 fixed: the named instance's setting, or the run's own Variable Font setting) and the font's version (`/5`). Without them, type in a named instance such as Bold opened at the font's default instance in the app that opens the file. Two settings of one font are now two fonts of the set; a static font's record is unchanged. The record's form comes from the public report in #6388. Test `ai_editing::tests::variable_font_instances_name_their_design_space` (#6388) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c15e19cda..2635623b5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -1416,7 +1416,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. 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 opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `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 opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests. A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type in an envelope with Distort Appearance on and strokes is mapped from its glyph box and, where that doesn't draw as the drawn art, from its box with strokes; the drawn-art check decides.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`). In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.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); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). From 198bb6dffd5f7296f5ae9ca6584a00f195d87af1 Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 21:53:21 +0000 Subject: [PATCH 5/7] .ai import: an envelope's grid reads its way round from every row and column Summing only the end rows and columns let a grid symmetric about its middle (a mesh bent halfway round) read by rounding, so it came out turned or not depending on where it sat on the page. Every row and column is now summed, a sum shorter than 1% of its parts (or than the grid's reach) counts as no direction and falls back to the first row or column, and the same comparison decides both cases. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/envelope.rs | 35 +++++++++++++++----- crates/eps/src/import/ai/tests.rs | 49 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 461be66fc..a3396a251 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -208,19 +208,38 @@ fn upright(m: &GradientMesh) -> Option<(u32, u32, Vec)> { let mut pts: Vec = m.points.iter().map(|q| (q.p, q.handles)).collect(); let at = |pts: &[GridPoint], cols: u32, r: u32, c: u32| pts.get((r * (cols + 1) + c) as usize).map_or(Point::ZERO, |q| q.0); let remap = |h: [Vec2; 4], slots: [usize; 4]| slots.map(|s| h.get(s).copied().unwrap_or(Vec2::ZERO)); - // How the grid runs along its rows (its first and last rows, end to end added up) and down its - // columns (its first and last columns): read from both sides, a grid bent far round (its end - // columns leaning more than halfway over) still reads the way it runs. + // How far the grid reaches: a run much shorter than that is rounding, not a direction. + let reach = { + let first = pts.first().map_or(Point::ZERO, |q| q.0); + let bounds = pts.iter().fold(Rect::from_points(first, first), |b, q| b.union_pt(q.0)); + bounds.width().hypot(bounds.height()) * 1e-6 + }; + // How the grid runs along its rows (every row end to end, added up) and down its columns (every + // column): read from all of them, a grid bent far round (its end columns leaning more than + // halfway over) still reads the way it runs. Each sum comes with how long its parts are: where + // they nearly cancel out, as in a ring, what is left is rounding, not a direction, and the grid + // reads by its first row or column instead. let runs = |pts: &[GridPoint], rows: u32, cols: u32| { - let along = (at(pts, cols, 0, cols) - at(pts, cols, 0, 0)) + (at(pts, cols, rows, cols) - at(pts, cols, rows, 0)); - let down = (at(pts, cols, rows, 0) - at(pts, cols, 0, 0)) + (at(pts, cols, rows, cols) - at(pts, cols, 0, cols)); - (along, down) + let (mut along, mut down, mut along_len, mut down_len) = (Vec2::ZERO, Vec2::ZERO, 0.0, 0.0); + for r in 0..=rows { + let v = at(pts, cols, r, cols) - at(pts, cols, r, 0); + (along, along_len) = (along + v, along_len + v.hypot()); + } + for c in 0..=cols { + let v = at(pts, cols, rows, c) - at(pts, cols, 0, c); + (down, down_len) = (down + v, down_len + v.hypot()); + } + let clear = |v: Vec2, len: f64| (v.hypot() >= len * 0.01 && v.hypot() > reach).then_some(v); + let first_row = at(pts, cols, 0, cols) - at(pts, cols, 0, 0); + let first_col = at(pts, cols, rows, 0) - at(pts, cols, 0, 0); + (clear(along, along_len).unwrap_or(first_row), clear(down, down_len).unwrap_or(first_col)) }; // How much of a run is across the page. let level = |v: Vec2| if v.hypot() > 0.0 { v.x.abs() / v.hypot() } else { 0.0 }; - // Columns that run across the page more than rows do: the grid goes the other way round. + // Columns that run across the page more than rows do: the grid goes the other way round. Nearly + // as much (a grid turned an eighth round) keeps the file's way round. let (along, down) = runs(&pts, rows, cols); - if level(down) > level(along) { + if level(down) > level(along) + 1e-3 { let mut t = Vec::with_capacity(len); for r in 0..=cols { for c in 0..=rows { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 039f8e1a8..7b208caf3 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7547,6 +7547,55 @@ fn a_mesh_envelope_bent_far_round_opens_the_way_it_runs() { assert!(r.warnings.is_empty(), "{:?}", r.warnings); } +/// A mesh envelope bent halfway round, its grid running down an arc from top to bottom so that its +/// end rows cancel out, opens the same way round wherever it sits on the page and whichever way +/// round the file writes it. +#[test] +fn a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is() { + use vectorcraft_doc::live::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP}; + for cx in [100.0, 100.1, 37.0, 250.3, 12.7] { + // 4 rows down an arc round (cx, 0) from 90 to 270 degrees, columns at radius 90 and 60. + let at = |(i, j): (usize, usize)| { + let (a, r) = ((90.0 + i as f64 * 45.0_f64).to_radians(), 60.0 + j as f64 * 30.0); + (cx + r * a.cos(), r * a.sin()) + }; + let handle = |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) + }; + // Row r of the envelope is the arc's point r from the top, column c the radius 1 - c: the + // outer one is on the left. + let doc_pt = |(x, y): (f64, f64)| Point::new(x, 100.0 - y); + let (mut points, mut handles) = (vec![], vec![]); + for r in 0..=4usize { + for c in 0..=1usize { + let g = (r, 1 - c); + let p = doc_pt(at(g)); + points.push(p); + let towards = |q: Option<(usize, usize)>| q.filter(|q| q.0 <= 4 && q.1 <= 1).map_or(kurbo::Vec2::ZERO, |q| doc_pt(handle(g, q)) - p); + let mut h = [kurbo::Vec2::ZERO; 4]; + h[H_DOWN] = towards(Some((g.0 + 1, g.1))); + h[H_UP] = towards(g.0.checked_sub(1).map(|i| (i, g.1))); + h[H_RIGHT] = towards(g.1.checked_sub(1).map(|j| (g.0, j))); + h[H_LEFT] = towards(Some((g.0, g.1 + 1))); + handles.push(h); + } + } + let want = EnvelopeKind::Mesh { rows: 4, cols: 1, points, handles }; + let along = mesh_text(4, 1, &at, &handle, None); + let across = mesh_text(1, 4, &|(i, j)| at((j, i)), &|p, q| handle((p.1, p.0), (q.1, q.0)), None); + for shape in [along, across] { + let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &envelope_content(), 2, &drawn_content(&want)))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + let EnvelopeKind::Mesh { rows, cols, points: got, .. } = live_envelope(&a[0]).1 else { panic!("at {cx}: {a:#?}") }; + assert_eq!((*rows, *cols), (4, 1), "at {cx}"); + let EnvelopeKind::Mesh { points: want, .. } = &want else { panic!("{want:?}") }; + assert!(got.iter().zip(want).all(|(p, q)| (*p - *q).hypot() < 1e-6), "at {cx}: {got:?}"); + } + } +} + /// An envelope made with a top object opens as a live envelope whose top path is the edge of its /// one-patch mesh, an anchor on the edge included; its contents stay the rectangle. #[test] From d0c99cba4bd0bbc1489b33f215ee68e974e1eb82 Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 21:53:21 +0000 Subject: [PATCH 6/7] .ai import: a warp of type's second try leaves checks for the envelopes after it A warp of type may now spend up to four checks (its warp and its saved grid, each with and without strokes). A try after the first is only made while more checks are left than there are envelopes of type and warped text objects still to come, so one envelope's retries don't use up the checks of the others. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/envelope.rs | 20 ++++--- crates/eps/src/import/tests_native.rs | 76 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index a3396a251..9bd3e937a 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -529,8 +529,11 @@ impl WarpedType { } let mut shown = BTreeSet::new(); doc.layers.iter().for_each(|l| collect_shown(l, &mut shown)); - for envelope in std::mem::take(&mut self.envelopes) { - match self.typed_live(doc, envelope, &type_of, &shown) { + let envelopes = std::mem::take(&mut self.envelopes); + let mut waiting = envelopes.len(); + for envelope in envelopes { + waiting -= 1; + match self.typed_live(doc, envelope, &type_of, &shown, waiting) { Ok(put_back) => { settled.live += 1; settled.put_back.push(put_back); @@ -541,14 +544,16 @@ impl WarpedType { settled } - /// [`Self::settle_envelopes`] for `envelope`, the shown objects of `doc` being `shown`: the live - /// envelope's id and what takes its place again, or why its drawn art stays. + /// [`Self::settle_envelopes`] for `envelope`, the shown objects of `doc` being `shown` and + /// `waiting` envelopes of type still to come: the live envelope's id and what takes its place + /// again, or why its drawn art stays. fn typed_live( &mut self, doc: &mut Document, envelope: TypedEnvelope, type_of: &impl Fn(&Document, NodeId) -> Option<(NodeId, Node)>, shown: &BTreeSet, + waiting: usize, ) -> Result<(NodeId, Vec), TypeKept> { let TypedEnvelope { obj, mut before, mut inner, drawn } = envelope; let art = doc.node(drawn).cloned().ok_or(TypeKept::Read)?; @@ -595,8 +600,11 @@ impl WarpedType { (plain.visible, plain.opacity) = (true, 1.0); let page = self.page.filter(|_| shown.contains(&drawn)); let mut alike = None; - for kind in kinds { - if self.checks == 0 { + for (i, kind) in kinds.into_iter().enumerate() { + // A second try is only made while more checks are left than there are envelopes of + // type and warped text objects still to come (some of which may need none): one + // envelope's tries don't use up the checks of the others. + if self.checks == 0 || (i > 0 && self.checks <= waiting + self.looks.len()) { return Err(TypeKept::Budget); } self.checks -= 1; diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index e8ad5a3f5..35ccdb5bd 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5826,6 +5826,82 @@ fn a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art() { assert_eq!(super::tests::program_warnings(&r.warnings), [crate::import::ai::ENVELOPE_TYPE_DRAWN], "{:?}", r.warnings); } +/// A warp of type tried again on its saved grid leaves a check for each envelope of type still to +/// come: with one check too few for both, the next envelope, whose first try opens it, gets it. +#[test] +fn a_warp_of_type_tried_again_leaves_the_next_envelope_its_check() { + use vectorcraft_doc::live::EnvelopeKind; + let Some(family) = installed_family() else { return }; + let story = ("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0); + let mut doc = text_document(&[story, story]).replace("(Helvetica)", &format!("({family})")); + // [`native_with_text`] writes 60 characters a line: the end of the data, `~>`, isn't split. + while ascii85(doc.as_bytes()).len() % 60 == 1 { + doc.push('\n'); + } + let text = made_envelope_type(&doc); + // The first envelope, of story 0, draws as its saved grid and not as its warp: two checks. + let mut shown = text.clone(); + shown.visible = true; + let options = vectorcraft_doc::live::EnvelopeOptions::NEW; + let shaped = Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { + content: vec![std::sync::Arc::new(shown)], + kind: Box::new(rising_kind()), + fidelity: 50.0, + editing: false, + options, + frame: Box::default(), + }, + ); + let (drawn_grid, page_grid) = drawn_envelope(&shaped); + let grid = type_envelope_on(&arc_keys(0.3), &rising_grid(), &drawn_grid, ""); + // The second, of story 1, draws as its warp: one check. + let (drawn_arc, page_arc) = drawn_envelope(&arc_envelope(&text, 30.0)); + let arc = type_envelope(&arc_keys(0.3), &drawn_arc).replace("%_0 /StoryIndex", "%_1 /StoryIndex"); + // Envelopes of art before them use all but `left` of the import's checks. + let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; + let rect = "20 5 m\n20 95 L\n80 95 L\n80 5 L\n20 5 L\n"; + let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); + let mesh = format!( + "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n", + corner(20.0, 5.0, 20.0, 35.0, 40.0, 5.0), + corner(20.0, 95.0, 40.0, 95.0, 20.0, 65.0), + corner(80.0, 95.0, 80.0, 65.0, 60.0, 95.0), + corner(80.0, 5.0, 60.0, 5.0, 80.0, 35.0), + ); + let red = "0 1 1 0 1 0 0 Xa\n"; + let filler = format!( + "{}%_/ArtDictionary :\n%_131073 /Int (AIDeformOptionsKey) ,\n%_50 /Int (AIDeformFidelityKey) ,\n%_0 /Bool (AIDeformEditModeKey) ,\n%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_2 /Int (AIDeformStyle) ,\n%_;\n%_\nu\n{red}{rect}f\nU\n(Adobe Deform Plugin) 1 0 0 XP\n", + hidden(&format!("u\n{mesh}U\n")), + hidden(&format!("u\n{red}{rect}f\nU\n")), + ); + let page = + format!("{}\n1 0 0 setrgbcolor 20 5 moveto 20 95 lineto 80 95 lineto 80 5 lineto closepath fill\n{page_grid}{page_arc}", page_square(10, 10)); + let file = |left: usize| { + let body = format!("{}\n{}{grid}{arc}", square(10, 10), filler.repeat(crate::import::ai::MAX_CHECKED - left)); + eps(&page, &native_with_text(&layer("Art", true, &body), &doc)) + }; + // The live envelopes of type, by kind. + let typed = |d: &Document| { + let mut out = vec![]; + d.walk(|n| { + if let NodeKind::Envelope { content, kind, .. } = &n.kind + && content.iter().any(|c| matches!(c.kind, NodeKind::Text(_))) + { + out.push(if matches!(**kind, EnvelopeKind::Mesh { .. }) { "mesh" } else { "warp" }); + } + }); + out + }; + let r = import(&file(3)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + assert_eq!(typed(&r.document), ["mesh", "warp"], "{:?}", r.warnings); + let r = import(&file(2)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + assert_eq!(typed(&r.document), ["warp"], "{:?}", r.warnings); +} + /// The fill of the first run of type `n`. fn text_fill(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None }; From d85a967fb7481d12d4f23d54bca6ed96947fa8c6 Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 21:53:21 +0000 Subject: [PATCH 7/7] docs: envelope grid orientation and retry budget for warps of type Co-Authored-By: Claude Opus 5.5 --- docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0d110ad45..6ef0a1d86 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -339,7 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| -| 2026-11-10 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. Type in an envelope with Distort Appearance on and strokes is also tried mapped from its box with strokes. A mesh bent far round (its end columns leaning more than halfway over) now reads the way it runs, not turned a quarter round. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs` (part of #3179) | +| 2026-11-11 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. A second try is only made while more checks are left than there are envelopes of type and warped text objects still to come. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box (a rule chosen by measuring real files). An envelope's grid (warp, mesh or top object) now reads its way round from all its rows and columns, not its first row alone: a mesh bent far round (its end columns leaning more than halfway over) reads the way it runs, not turned a quarter round, and a grid symmetric about its middle reads the same wherever it is on the page. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `a_warp_of_type_tried_again_leaves_the_next_envelope_its_check`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs`, `a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is` (part of #3179) | | 2026-11-11 | minor | `.ai` and EPS import: type stroked in the text document keeps its stroke's cap, join and miter limit (keys 61, 62 and 64 of its character attributes, numbered as PostScript's `setlinecap` and `setlinejoin`: 0 butt or miter, 1 round, 2 projecting or bevel). They were left at a miter join with a limit of 10 whatever the file said, so type the file strokes with round joins, or with the usual limit of 4 or less, drew spikes at its sharp corners, some many points long. In users' files, used locally and never committed, the page strokes the type with the cap, join and miter limit these keys give; no file had a bevel join, which is read by the same numbering. A value that isn't one of theirs keeps the default, a miter limit past 500 is 500, and type without a stroke keeps the defaults. Dashes are not read: no file had dashed type to tell which keys hold them. Tests `ate::tests::a_run_keeps_its_stroke_cap_join_and_miter_limit`, `tests_native::stroked_type_keeps_its_cap_join_and_miter_limit`; scores unchanged | | 2026-11-10 | minor | `.ai` and EPS import: an envelope of type (a warp, a mesh or a top object around type) kept its drawn art, its type hidden, because the type is laid out from the text document only after the layers are read. It now waits for that: its text slots get their type, and where VectorCraft's own envelope of that type draws as the drawn art the file saves (the same check and budget as other envelopes, seen on white and on black), it opens live in the drawn art's place, its type editable in Edit Contents, and the hidden copy goes. As with other live envelopes, the layers with one are then compared with the page, which weighs the envelope as the art it draws (not as type laid out again, which the comparison allows more for), and when they don't draw as it the drawn art and the hidden type are put back. Layers used though they don't draw as the page (the editing data trusted, or the objects that differ coming in as the page's art) leave envelopes of type their drawn art and hidden type too. A `.ai` whose layers would open alone, without its PDF part, imports the PDF part to compare them with, and should they still not draw as it, or should no envelope of type open live, they open alone as before, with the envelopes' drawn art. Type in a font that isn't available, slots without type of their own (threaded, bound to a text or a visibility variable, a wrap object, type a look draws), envelopes in a symbol's or a pattern's art, and envelopes that draw differently keep their drawn art, with the note (#3179). Tests `tests_native::an_envelope_of_type_that_draws_as_its_drawn_art_opens_live`, `an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone`, `an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live`, `trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art`, `layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art`, `an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art` | | 2026-11-10 | minor | `.ai` save: a variable font's record in the text document's font set names the type's place in the font's design space (`/4`, each `fvar` axis as 16.16 fixed: the named instance's setting, or the run's own Variable Font setting) and the font's version (`/5`). Without them, type in a named instance such as Bold opened at the font's default instance in the app that opens the file. Two settings of one font are now two fonts of the set; a static font's record is unchanged. The record's form comes from the public report in #6388. Test `ai_editing::tests::variable_font_instances_name_their_design_space` (#6388) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2635623b5..500208265 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -1416,7 +1416,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. 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 opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests. A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type in an envelope with Distort Appearance on and strokes is mapped from its glyph box and, where that doesn't draw as the drawn art, from its box with strokes; the drawn-art check decides.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 opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box; the drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. 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`). In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.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); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382).