From 5f0d31061440558bafeeca845941701fbf2790e8 Mon Sep 17 00:00:00 2001 From: jmverges Date: Thu, 12 Nov 2026 07:27:56 +0000 Subject: [PATCH] .ai import: type in an envelope is also tried mapped from the box of what it paints (#6824) With Envelope Options > Distort Appearance on, the import tries type in an envelope mapped from its glyphs, and from its glyphs grown by half its strokes' weight (#6535), on its own warp and on its saved grid. In users' files the drawn art of some stroked type maps from the box of what the type paints instead, which differs from those where: - the type has sharp corners at its edges (slanted stems, pointed feet) and miter joins: the miter tip pokes out past half the weight, as far as the miter limit lets it, though not as far as the bounds with strokes allow (a miter at every corner); - a stroke carries a Transform effect that moves it (a drop shadow): the moved stroke paints past the glyphs on that side; - a glyph holds a contour of zero length at its edge, which paints nothing: the glyphs' box counts it. Such an envelope failed the drawn-art check and kept its drawn art. After every way it was tried before, and before a grid moved onto its drawn art, the envelope is now also tried mapped from what its type paints, where that box is another: its glyph outlines with their fills and strokes baked as the envelope bakes them under Distort Appearance (geometry effects applied), each piece measured by its visual bounds. Those are exact for miter joins (a tip counts where the limit keeps it) and round joins, and an upper bound for bevel joins, miters the limit cuts, width profiles and square-capped dashes. Effects on the type object itself are left out, as VectorCraft applies them after the envelope bends the type. Groups round type and other objects in the contents are measured as before. A side within 1e-4 pt of another box's counts as that side, so a box measured two ways isn't tried twice. The box is measured only when its try comes and a check is left for it, and once for an envelope: on a synthetic file of 256 envelopes of 2,000 characters each the import takes 13.3 s, as before, where measuring it up front took 14.7 s. On 224 of our own files (used locally, never committed), envelopes of type that open live go from 154 in 45 files to 161 in 50, none fewer in any file; no file changes whether it opens from its layers or its PDF part. Tests: ai::envelope::tests:: mitred_type_is_also_tried_mapped_from_what_its_stroke_paints_miter_tips_included, type_with_a_mitred_character_stroke_is_also_tried_mapped_from_what_it_paints, a_stroke_moved_by_a_transform_effect_counts_where_it_paints, round_joined_type_is_not_tried_twice_for_the_same_box, a_contour_of_zero_length_paints_nothing, a_side_within_the_slack_of_the_geometric_box_is_that_side, and tests_native::a_warp_of_mitred_type_maps_from_what_its_stroke_paints_where_its_drawn_art_does. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/envelope.rs | 326 +++++++++++++++++++++++++- crates/eps/src/import/tests_native.rs | 78 ++++++ docs/file-format-parity.md | 3 +- docs/target-app-parity.md | 5 +- 4 files changed, 396 insertions(+), 16 deletions(-) diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 8b3fceecf3..d117dc83c6 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -189,6 +189,111 @@ fn with_strokes(kind: &NodeKind) -> Option { Some(kind) } +/// How far, in points, the sides of two boxes may lie apart and still count as the same side: the +/// glyphs' box (from type's ink) and the box of what type paints (from its outlines) measure one +/// outline two ways, and where they meet they differ by rounding only ([`painted_margin`], +/// [`same_box`]). +const PAINTED_SLACK: f64 = 1e-4; + +/// The box of what the pieces of type's outlines `outlines` ([`vectorcraft_effects::outline_text`]) +/// paint as an envelope with Distort Appearance bends them: each piece baked as the envelope bakes +/// it ([`vectorcraft_effects::bake_type_appearance`]: geometry effects on its fills and strokes +/// applied, so a stroke a Transform effect moves counts where it is moved to), measured by its +/// visual bounds. Those are exact for miter joins (a tip counts where the miter limit keeps it) and +/// round joins, and an upper bound for bevel joins, miters the limit cuts, width profiles and +/// square-capped dashes; a contour of zero length, which paints nothing, doesn't count. Effects on +/// the type object itself are left out: VectorCraft applies them after the envelope bends the type. +/// `None` when nothing in it has bounds. +fn painted_outlines(outlines: &Node) -> Option { + outlines.children().into_iter().flatten().fold(None, |acc, piece| { + let baked = vectorcraft_effects::bake_type_appearance(None, piece); + vectorcraft_geom::union_opt(acc, baked.as_ref().unwrap_or(piece).visual_bounds()) + }) +} + +/// The box of `content` an envelope of type with Distort Appearance may map from in a file besides +/// [`half_stroked_box`]: what it paints, type by its outlines ([`painted_outlines`]), groups by +/// their members grown by as far as their own strokes paint along a side, other objects by their +/// bounds with strokes. `None` when nothing in it has bounds. +fn painted_box(content: &[Arc]) -> Option { + fn walk(n: &Arc, acc: Option) -> Option { + match &n.kind { + NodeKind::Text(_) => vectorcraft_geom::union_opt(acc, vectorcraft_effects::outline_text(n).as_ref().and_then(painted_outlines)), + NodeKind::Group { children, clip: false } => { + let along = n.appearance.painted_strokes().map(|s| s.side_reach(true)).fold(0.0, f64::max); + let members = children.iter().fold(None, |acc, c| walk(c, acc)); + let grown = |r: Rect| if along.is_finite() && along > 0.0 { r.inflate(along, along) } else { r }; + vectorcraft_geom::union_opt(acc, members.map(grown)) + } + _ => vectorcraft_geom::union_opt(acc, envelope_content_boxes(std::slice::from_ref(n), Affine::IDENTITY).map(|(stroked, _)| stroked)), + } + } + content.iter().fold(None, |acc, c| walk(c, acc)) +} + +/// The bounds margin of `painted`, the box of what contents with geometric box `plain` paint, a side +/// within [`PAINTED_SLACK`] of the geometric box's counting as that side. `None` where it lies +/// inside the geometric box on a side, where it is that box, and for margins that don't fit. +fn painted_margin(painted: Rect, plain: Rect) -> Option<[f32; 4]> { + let side = |p: f64, g: f64| if (p - g).abs() <= PAINTED_SLACK { g } else { p }; + let painted = Rect::new(side(painted.x0, plain.x0), side(painted.y0, plain.y0), side(painted.x1, plain.x1), side(painted.y1, plain.y1)); + let margin = envelope_stroke_margin(painted, plain)?; + (margin.iter().all(|m| *m >= 0.0) && margin.iter().any(|m| *m > 0.0)).then_some(margin) +} + +/// Do bounds margins `a` and `b` give contents with geometric box `plain` the same box, each side +/// within [`PAINTED_SLACK`]? +fn same_box(a: [f32; 4], b: [f32; 4], plain: Rect) -> bool { + let sizes = [plain.height(), plain.height(), plain.width(), plain.width()]; + a.iter().zip(b).zip(sizes).all(|((a, b), size)| (f64::from(*a) - f64::from(b)).abs() * size <= PAINTED_SLACK) +} + +/// What the contents of an envelope of type paint ([`painted_box`]): their geometric box and the +/// bounds margin of what they paint from it. +#[derive(Clone, Copy)] +struct Painted { + plain: Rect, + margin: [f32; 4], +} + +/// [`Painted`] for envelope `kind` with Distort Appearance; `None` without it, and where +/// [`painted_margin`] gives none. +fn painted_of(kind: &NodeKind) -> Option { + let NodeKind::Envelope { content, options, .. } = kind else { return None }; + if !options.distort_appearance { + return None; + } + let (_, plain) = envelope_content_boxes(content, Affine::IDENTITY)?; + Some(Painted { plain, margin: painted_margin(painted_box(content)?, plain)? }) +} + +/// Envelope `kind` mapping from what its contents paint (`painted`, [`painted_of`]), for an +/// envelope with Distort Appearance where that box is neither the one it maps from already nor its +/// contents' glyphs grown by half their strokes' weight ([`with_strokes`]); `None` otherwise. +fn as_painted(kind: &NodeKind, painted: &Painted) -> Option { + let NodeKind::Envelope { options, .. } = kind else { return None }; + let half = match with_strokes(kind) { + Some(NodeKind::Envelope { options, .. }) => Some(options.bounds_margin), + _ => None, + }; + let Painted { plain, margin } = *painted; + if !options.distort_appearance || same_box(margin, options.bounds_margin, plain) || half.is_some_and(|h| same_box(margin, h, plain)) { + return None; + } + let mut kind = kind.clone(); + if let NodeKind::Envelope { options, .. } = &mut kind { + options.bounds_margin = margin; + } + Some(kind) +} + +/// A way an envelope of type is tried: a kind, or a kind mapping from what its type paints +/// ([`as_painted`]), worked out when its turn comes. +enum Way { + As(NodeKind), + Painted(NodeKind), +} + /// The kind of `envelope`, a mesh or a top object in Edit Contents, with its grid moved onto /// `drawn`, the drawn art the file saves for it, where what it draws is as large as that art (within /// [`MOVED_SLACK`]) but lies elsewhere (further than that): moved by as far as the drawn art's box @@ -592,7 +697,8 @@ impl WarpedType { /// 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. With Distort Appearance, each /// is also checked mapping from its type's glyphs grown by half their strokes' weight - /// ([`with_strokes`]). In Edit Contents, a mesh or top object whose grid draws the type + /// ([`with_strokes`]) and, after all of those, from what its type paints where that is another + /// box ([`as_painted`]). In Edit Contents, a mesh or top object whose grid draws the type /// elsewhere than its drawn art is checked last moved onto that art ([`moved_onto`]). 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 @@ -659,17 +765,16 @@ impl WarpedType { // With Distort Appearance, a file's envelope may map its type from its glyphs grown by half // their strokes' weight or from the glyphs alone, 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(); + // After all of those, each is checked mapping from what its type paints, where that box is + // another: it is measured only when such a check comes and one is left. + let kinds: Vec = live(&obj, before, inner).into_iter().chain(on_grid).collect(); if kinds.is_empty() { return Err(TypeKept::Read); } + let mut ways: Vec = kinds.iter().flat_map(|k| std::iter::once(Way::As(k.clone())).chain(with_strokes(k).map(Way::As))).collect(); + ways.extend(kinds.into_iter().map(Way::Painted)); + // What the type paints, measured once: the kinds share their contents. + let mut painted: Option> = None; let (id, layer) = (doc.alloc_id(), doc.alloc_id()); let mut plain = art.clone(); (plain.visible, plain.opacity) = (true, 1.0); @@ -677,9 +782,9 @@ impl WarpedType { let mut alike = None; // In Edit Contents, a grid that draws the type elsewhere than its drawn art is tried again // moved onto that art ([`moved_onto`]), after every other way. - let (mut tries, mut moved, mut again, mut i) = (kinds.into_iter(), vec![], false, 0); + let (mut tries, mut moved, mut again, mut i) = (ways.into_iter(), vec![], false, 0); loop { - let Some(kind) = tries.next() else { + let Some(way) = tries.next() else { if again || moved.is_empty() { break; } @@ -692,6 +797,13 @@ impl WarpedType { if self.checks == 0 || (i > 0 && self.checks <= waiting + self.looks.len()) { return Err(TypeKept::Budget); } + let kind = match way { + Way::As(kind) => kind, + Way::Painted(base) => match painted.get_or_insert_with(|| painted_of(&base)).as_ref().and_then(|p| as_painted(&base, p)) { + Some(kind) => kind, + None => continue, + }, + }; self.checks -= 1; i += 1; let mut envelope = art.clone(); @@ -704,7 +816,7 @@ impl WarpedType { break; } if !again && let Some(kind) = moved_onto(&live_one, &plain) { - moved.push(kind); + moved.push(Way::As(kind)); } } let envelope = alike.ok_or(TypeKept::Differs)?; @@ -866,7 +978,7 @@ fn alike_within(live: &Node, drawn: &Node, reach: Option, layer: NodeId, s mod tests { use super::*; use vectorcraft_color::{Color, Paint}; - use vectorcraft_doc::{AppearanceItem, CharStyle, LineJoin, StrokeLayer, TextObject}; + use vectorcraft_doc::{AppearanceItem, CharStyle, LineCap, LineJoin, StrokeLayer, TextObject}; /// An envelope of `content` with Distort Appearance, on a flat one-patch mesh. fn envelope_of(content: Vec>) -> NodeKind { @@ -948,4 +1060,192 @@ mod tests { let (_, glyphs) = envelope_content_boxes(&content, Affine::IDENTITY).unwrap(); assert_eq!(stroked_margin(content), envelope_stroke_margin(glyphs.inflate(2.0, 2.0), glyphs)); } + + /// Is the font type takes by default installed? Type that must draw as itself is tested only + /// where it is: the tests below measure its glyphs. + fn default_font() -> bool { + vectorcraft_text::FontDb::global().has_family(&CharStyle::default().font_family) + } + + /// The bounds margin of the candidate mapping from what `content` paints, for an envelope of it, + /// type measured by its glyphs as the import measures it; `None` where there is none. + fn painted_candidate(content: Vec>) -> Option<[f32; 4]> { + vectorcraft_text::register_envelope_ink(); + let kind = envelope_of(content); + match as_painted(&kind, &painted_of(&kind)?) { + Some(NodeKind::Envelope { options, .. }) => Some(options.bounds_margin), + _ => None, + } + } + + /// The box contents with geometric box `plain` map from with bounds margin `m`. + fn box_of(m: [f32; 4], plain: Rect) -> Rect { + let [top, bottom, left, right] = m.map(f64::from); + Rect::new( + plain.x0 - left * plain.width(), + plain.y0 - top * plain.height(), + plain.x1 + right * plain.width(), + plain.y1 + bottom * plain.height(), + ) + } + + /// Are the sides of `a` and `b` within `tol` of each other? + fn close(a: Rect, b: Rect, tol: f64) -> bool { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() <= tol) + } + + /// The geometric box of `content`, type measured by its glyphs as the import measures it. + fn glyphs_of(content: &[Arc]) -> (Rect, Rect) { + vectorcraft_text::register_envelope_ink(); + envelope_content_boxes(content, Affine::IDENTITY).unwrap() + } + + /// What the type in `content` paints, measured apart from the import's own measure: each piece + /// of its outlines filled, and outlined by kurbo's stroker with each of its strokes' weight, + /// join and miter limit. + fn stroked_by_kurbo(content: &[Arc]) -> Rect { + use kurbo::Shape; + let mut out: Option = None; + for n in content { + let outlines = vectorcraft_effects::outline_text(n).unwrap(); + for piece in outlines.children().unwrap() { + let bp = piece.stroke_path().unwrap(); + out = vectorcraft_geom::union_opt(out, Some(bp.bounding_box())); + for s in piece.appearance.painted_strokes() { + let join = match s.join { + LineJoin::Miter => kurbo::Join::Miter, + LineJoin::Round => kurbo::Join::Round, + LineJoin::Bevel => kurbo::Join::Bevel, + }; + let stroke = kurbo::Stroke::new(s.width).with_join(join).with_miter_limit(s.miter_limit); + let outline = kurbo::stroke(bp.iter(), &stroke, &kurbo::StrokeOpts::default(), 1e-4); + out = vectorcraft_geom::union_opt(out, Some(outline.bounding_box())); + } + } + } + out.unwrap() + } + + /// Type with Distort Appearance and a stroke with miter joins on its appearance is also tried + /// mapped from what that stroke paints: past half its weight where a miter tip pokes out at a + /// sharp corner (the feet of the A's), though not as far as its bounds with strokes, which allow + /// for a miter as long as the limit lets one be at every corner. + #[test] + fn mitred_type_is_also_tried_mapped_from_what_its_stroke_paints_miter_tips_included() { + if !default_font() { + return; + } + let mut text = type_node(1, CharStyle::default()); + let mut appearance = (*text.appearance).clone(); + appearance.items.push(AppearanceItem::Stroke(mitred())); + text.appearance = appearance.into(); + let content = vec![Arc::new(text)]; + let (with, glyphs) = glyphs_of(&content); + let (half, painted) = (glyphs.inflate(2.0, 2.0), stroked_by_kurbo(&content)); + assert!(painted.x0 < half.x0 - 0.5 && painted.x1 > half.x1 + 0.5, "{painted:?} {half:?}"); + assert!(painted.width() < with.width() - 0.5, "{painted:?} {with:?}"); + let from = box_of(painted_candidate(content).unwrap(), glyphs); + assert!(close(from, painted, 0.01), "{from:?} {painted:?}"); + } + + /// Type stroked by its characters with miter joins is also tried mapped from what those strokes + /// paint, turned and stretched as well as upright. + #[test] + fn type_with_a_mitred_character_stroke_is_also_tried_mapped_from_what_it_paints() { + if !default_font() { + return; + } + let style = CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 4.0, stroke_join: LineJoin::Miter, ..CharStyle::default() }; + for xf in [ + Affine::IDENTITY, + Affine::rotate(std::f64::consts::FRAC_PI_4), + Affine::scale_non_uniform(std::f64::consts::SQRT_2, std::f64::consts::FRAC_1_SQRT_2), + ] { + let mut text = type_node(1, style.clone()); + if let NodeKind::Text(t) = &mut text.kind { + t.xf = xf * t.xf; + } + let content = vec![Arc::new(text)]; + let (_, glyphs) = glyphs_of(&content); + let painted = stroked_by_kurbo(&content); + let from = box_of(painted_candidate(content).unwrap(), glyphs); + assert!(close(from, painted, 0.01), "{xf:?}: {from:?} {painted:?}"); + } + } + + /// A stroke that a Transform effect moves paints where it is moved to: type filled over its + /// glyphs with a round 4 point stroke moved 10 points down is also tried mapped from its glyphs + /// grown by 2 points on the left and right and 12 at the bottom, and not at the top. + #[test] + fn a_stroke_moved_by_a_transform_effect_counts_where_it_paints() { + if !default_font() { + return; + } + let mut text = type_node(1, CharStyle::default()); + let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + (stroke.join, stroke.cap) = (LineJoin::Round, LineCap::Round); + stroke.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({ "moveV": 10.0 }), visible: true }); + let mut appearance = (*text.appearance).clone(); + appearance.items.push(AppearanceItem::Stroke(stroke)); + text.appearance = appearance.into(); + let content = vec![Arc::new(text)]; + let (_, glyphs) = glyphs_of(&content); + let painted = Rect::new(glyphs.x0 - 2.0, glyphs.y0, glyphs.x1 + 2.0, glyphs.y1 + 12.0); + let from = box_of(painted_candidate(content).unwrap(), glyphs); + assert!(close(from, painted, 0.01), "{from:?} {painted:?}"); + } + + /// Type whose round-joined stroke paints half its weight past its glyphs everywhere is not tried + /// a second time mapped from what it paints: that is the box of its glyphs grown by half the + /// weight, already tried. + #[test] + fn round_joined_type_is_not_tried_twice_for_the_same_box() { + if !default_font() { + return; + } + let style = CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 4.0, stroke_join: LineJoin::Round, ..CharStyle::default() }; + let content = vec![Arc::new(type_node(1, style))]; + vectorcraft_text::register_envelope_ink(); + assert!(with_strokes(&envelope_of(content.clone())).is_some()); + assert_eq!(painted_candidate(content), None); + } + + /// A contour of zero length paints nothing, so it isn't part of what type's outlines paint: a + /// filled square with a round 2 point stroke and such a contour 5 points above it paints the + /// square grown by 1 point. + #[test] + fn a_contour_of_zero_length_paints_nothing() { + let mut bp = BezPath::new(); + bp.move_to((10.0, 10.0)); + bp.line_to((20.0, 10.0)); + bp.line_to((20.0, 20.0)); + bp.line_to((10.0, 20.0)); + bp.close_path(); + bp.move_to((15.0, 5.0)); + bp.line_to((15.0, 5.0)); + bp.close_path(); + let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + (stroke.join, stroke.cap) = (LineJoin::Round, LineCap::Round); + let mut appearance = Appearance::default(); + appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::solid(Color::BLACK)))); + appearance.items.push(AppearanceItem::Stroke(stroke)); + let piece = Node::path(NodeId(1), PathData::from_bezpath(&bp), appearance); + let outlines = Node::group(NodeId(2), vec![Arc::new(piece)]); + assert_eq!(painted_outlines(&outlines), Some(Rect::new(9.0, 9.0, 21.0, 21.0))); + } + + /// A side of what contents paint within a ten-thousandth of a point of their geometric box's is + /// that side, inside it or out: the two boxes measure the same outline two ways. Further inside, + /// there is no box; further out, it counts. + #[test] + fn a_side_within_the_slack_of_the_geometric_box_is_that_side() { + let plain = Rect::new(10.0, 20.0, 110.0, 70.0); + let painted = Rect::new(8.0, 20.0 + 1e-9, 112.0, 72.0); + assert_eq!(painted_margin(painted, plain), Some([0.0, 0.04, 0.02, 0.02])); + assert_eq!(painted_margin(Rect::new(8.0, 20.001, 112.0, 72.0), plain), None); + assert_eq!(painted_margin(plain, plain), None); + let half = [0.04_f32, 0.04, 0.02, 0.02]; + assert!(same_box(half, [0.04 + 1e-7, 0.04, 0.02, 0.02], plain)); + assert!(!same_box(half, [0.041, 0.04, 0.02, 0.02], plain)); + } } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 8f64280367..dcce2d1f91 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5834,6 +5834,84 @@ fn a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_ assert_eq!(margin, Some(options.bounds_margin)); } +/// What the type in `content` paints, measured apart from the import: each piece of its outlines +/// filled, and outlined by kurbo's stroker with each of its strokes' weight, join and miter limit. +fn painted_by_kurbo(content: &[std::sync::Arc]) -> kurbo::Rect { + use kurbo::Shape; + let mut out: Option = None; + for n in content { + let outlines = vectorcraft_effects::outline_text(n).unwrap(); + for piece in outlines.children().unwrap() { + let bp = piece.stroke_path().unwrap(); + out = vectorcraft_geom::union_opt(out, Some(bp.bounding_box())); + for s in piece.appearance.painted_strokes() { + let join = match s.join { + vectorcraft_doc::LineJoin::Miter => kurbo::Join::Miter, + vectorcraft_doc::LineJoin::Round => kurbo::Join::Round, + vectorcraft_doc::LineJoin::Bevel => kurbo::Join::Bevel, + }; + let stroke = kurbo::Stroke::new(s.width).with_join(join).with_miter_limit(s.miter_limit); + let outline = kurbo::stroke(bp.iter(), &stroke, &kurbo::StrokeOpts::default(), 1e-4); + out = vectorcraft_geom::union_opt(out, Some(outline.bounding_box())); + } + } + } + out.unwrap() +} + +/// A warp of type stroked with miter joins, Distort Appearance on, whose drawn art maps the type from +/// what it paints, the miter tips at the sharp corners of its X past half the stroke's weight +/// included, as users' files map some such type, opens live as a mesh envelope on its saved grid +/// mapping from that box, where its glyphs grown by half the weight don't draw as that art (#6824). +#[test] +fn a_warp_of_mitred_type_maps_from_what_its_stroke_paints_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, with miter joins at a limit of 10. + 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 /62 0 /63 4.0 /64 10.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_join == vectorcraft_doc::LineJoin::Miter && t.runs[0].style.stroke_miter_limit == 10.0), + "{text:?}" + ); + let content = vec![std::sync::Arc::new(text)]; + let (half, glyphs) = envelope_content_boxes(&content, kurbo::Affine::IDENTITY).unwrap(); + let painted = painted_by_kurbo(&content); + assert!(painted.x0 < half.x0 - 0.5 || painted.x1 > half.x1 + 0.5, "{painted:?} {half:?}"); + let mut options = vectorcraft_doc::live::EnvelopeOptions::NEW; + options.bounds_margin = envelope_stroke_margin(painted, glyphs).unwrap(); + 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_stroked_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:?}"); + // It maps from what the type paints, within a hundredth of a point on each side. + let mut margin = None; + r.document.walk(|n| { + if let NodeKind::Envelope { options, .. } = &n.kind { + margin = Some(options.bounds_margin); + } + }); + let [top, bottom, left, right] = margin.unwrap().map(f64::from); + let from = kurbo::Rect::new( + glyphs.x0 - left * glyphs.width(), + glyphs.y0 - top * glyphs.height(), + glyphs.x1 + right * glyphs.width(), + glyphs.y1 + bottom * glyphs.height(), + ); + let off = [from.x0 - painted.x0, from.y0 - painted.y0, from.x1 - painted.x1, from.y1 - painted.y1]; + assert!(off.iter().all(|d| d.abs() < 0.01), "{from:?} {painted:?}"); +} + /// The drawn art of `envelope` (on the page axes), an envelope of type stroked by its characters, /// as the drawn art of `.ai` files strokes type in an envelope: the bent glyph outlines the envelope /// draws of its type unstroked, each filled in its type's color, then each stroked with the type's diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 23a36a7e9a..75c95bb33a 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, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, its strokes' miter tips and strokes a Transform effect moves included, after its glyphs grown by half its strokes' weight, #6824; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.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 @@ -340,6 +340,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, after the ways it was tried before (its glyphs alone and grown by half its strokes' weight, #6535, on its own warp and on its saved grid), before a grid moved onto its drawn art, and only where that box is another: its glyph outlines with their fills and strokes baked as the envelope bakes them (geometry effects on them applied: a stroke a Transform effect moves counts where it is moved to), each piece measured by its visual bounds, which are exact for miter joins (a tip counts where the miter limit keeps it, not the miter allowance at every corner that the bounds with strokes take) and round joins and an upper bound for bevel joins, miters the limit cuts, width profiles and square-capped dashes; a contour of zero length, which paints nothing, doesn't count. Effects on the type object itself are left out, as VectorCraft applies them after the envelope bends the type. The box is measured only when that try comes and the import has a check left for it, and once for an envelope. Groups round type and other objects in the contents are measured as before. In users' files (used locally, never committed) the drawn art of some stroked type in an envelope maps from that box: where the type has sharp corners at its edges (slanted stems, pointed feet) and miter joins, or a stroke moved by a Transform effect (a drop shadow), its glyphs grown by half the weight gave too small a box and the envelope kept its drawn art; and where a glyph holds a contour of zero length at its edge, the glyphs' box counted it. On 224 of our own files: envelopes of type that open live go from 154 in 45 files to 161 in 50, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 6 files, by at most 0.94% of their pixels, each where an envelope of type now opens live in place of its drawn art (the page's own art): 1 closer to the file's own page and 5 further; the notes change in the same 6 files: the note on type in an envelope goes where none keeps its drawn art, and a file whose envelopes of type all kept their drawn art, which opened without the page comparison, now opens with it and carries its notes (the share of art or type that differs from the page, type the page has without a text object, the PDF part's note on DeviceN colours). A synthetic file of 256 such envelopes of 2,000 characters each opens in the same time as before (13.3 s in a debug build): the box is measured only when its try comes. Tests `ai::envelope::tests::mitred_type_is_also_tried_mapped_from_what_its_stroke_paints_miter_tips_included`, `type_with_a_mitred_character_stroke_is_also_tried_mapped_from_what_it_paints`, `a_stroke_moved_by_a_transform_effect_counts_where_it_paints`, `round_joined_type_is_not_tried_twice_for_the_same_box`, `a_contour_of_zero_length_paints_nothing`, `a_side_within_the_slack_of_the_geometric_box_is_that_side`, `tests_native::a_warp_of_mitred_type_maps_from_what_its_stroke_paints_where_its_drawn_art_does` (#6824, part of #3179) | | 2026-11-11 | minor | `.ai` and EPS import, and what the canvas, every export and `.ai` save draw: type's strokes in an envelope (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight, Distort Appearance on or off, as the drawn art of users' `.ai` files strokes them (used locally, never committed: there the drawn art keeps those strokes as stroked paths at the type's own weights, and its ring is as wide wherever the grid squeezes or stretches the type). With Distort Appearance, VectorCraft bent them with the art, as their filled outlines, so an envelope of stroked type in a squeezed or stretched grid didn't draw as its drawn art and kept it. The same holds in an envelope inside an envelope. Paths' strokes, geometry effects, opacity masks, linear gradients and pattern fills bend as before. On 224 of our own files: envelopes of type that open live go from 135 in 37 files to 154 in 45, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 31 files, by at most 2.3% of their pixels, 19 closer to the file's own page and 12 further, 11 of those where an envelope of type now opens live in place of its drawn art (the page's own art) and one by 4 pixels. Tests `envelope_distort::type_strokes_paint_at_their_own_weight_in_a_squeezed_envelope`, `a_stroke_on_types_appearance_paints_at_its_own_weight_in_a_squeezed_envelope`, `a_paths_stroke_still_bends_in_a_squeezed_envelope`, `tests_native::an_envelope_of_stroked_type_whose_drawn_art_strokes_the_bent_outlines_at_the_weight_opens_live` (#6644) | | 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b1ceaf70ea..b8c8a86178 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-12 · **Change:** minor (Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously 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-12 · **Change:** minor (`.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; scores and estimates unchanged); previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously 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) > > Live Paint Bucket update (2026-11-12): one drag fills the faces crossed in the same group (#6373). > @@ -1482,7 +1482,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`). 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 glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, 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. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. The drawn art of these files strokes type in an envelope along its bent outlines at the stroke's own weight, Distort Appearance on or off, and VectorCraft draws it so (#6644): an envelope of stroked type whose grid squeezes or stretches it opens live where it draws as that art. + 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 glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, where the size the file saves for it doesn't already give that box; after those tries (and before a grid moved onto its drawn art), it is also tried mapped from the box of what it paints, where that is another box, as users' files map some such type: its glyph outlines with their fills and strokes baked as the envelope bakes them, so geometry effects on them count (a stroke a Transform effect moves paints where it is moved to), each measured by its visual bounds (exact for miter joins, a tip counting where the miter limit keeps it, and for round joins; an upper bound for bevel joins, miters the limit cuts, width profiles and square-capped dashes), and a contour of zero length, which paints nothing, left out; effects on the type object itself are left out, as VectorCraft applies them after the envelope bends the type. 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. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. The drawn art of these files strokes type in an envelope along its bent outlines at the stroke's own weight, Distort Appearance on or off, and VectorCraft draws it so (#6644): an envelope of stroked type whose grid squeezes or stretches it opens live where it draws as that art. 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). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, off by default) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard. @@ -2056,6 +2056,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints (its strokes' miter tips, and strokes a Transform effect moves, included), after its glyphs grown by half its strokes' weight; `.ai` import evidence updated (#6824); scores and estimates unchanged. | | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does (Smart Guides, Snap to Point and the ruler guides, pixels, the grid); evidence updated; scores unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. |