.ai import: anchors are smooth or corners by the case of their segment operators

In editing data and legacy .ai files, a lower-case l, c, v or y makes the
anchor the segment ends on smooth and an upper-case L, C, V or Y makes it a
corner. The reader used to guess each anchor's kind from whether its
handles are in line, so a lower-case l always read as a corner and an
upper-case C with handles in line read as smooth. A closed path's first
anchor takes the case of the segment back to it. Points and handles do not
move. Paths, compound paths, clipping paths, guide paths, area type frames
and type-on-a-path paths all keep the kinds.
This commit is contained in:
dotCooCoo
2026-11-02 12:04:23 +07:00
committed by bflatastic
parent ae46139b20
commit 13db3cd602
8 changed files with 281 additions and 27 deletions
+5 -2
View File
@@ -2645,6 +2645,9 @@ fn editing_text() -> String {
square(40),
"50 10 m 60 10 L 60 20 L 50 20 L h W f".into(),
"Q".into(),
// Smooth and corner anchors by the case of each segment's operator, back to the start.
"10 50 m 20 60 30 50 40 50 c 45 50 50 55 v 55 60 60 60 Y 60 50 l 50 40 30 40 10 50 C s".into(),
"70 50 m 80 50 l 80 60 L 70 60 y h S".into(),
"1 Xw".into(),
"u".into(),
"/AI11Text :\n0 /FreeUndo ,\n;".into(),
@@ -3080,7 +3083,7 @@ proptest! {
/// An Illustrator EPS whose editing data is damaged or hostile: read as its layers, or as its page.
#[test]
fn eps_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12)) {
fn eps_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'l', 'c', 'C', 'v', 'V', 'y', 'Y', 'k', 'x'])), 0..12)) {
let text = mutate_text(&editing_text(), cut, &edits);
let bytes = editing_eps(&text);
survive("mutated EPS editing data", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
@@ -3122,7 +3125,7 @@ proptest! {
/// A `.ai` whose editing data is damaged or hostile.
#[test]
fn ai_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12), damage in prop::collection::vec((0usize..4_000, any::<u8>()), 0..4)) {
fn ai_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'l', 'c', 'C', 'v', 'V', 'y', 'Y', 'k', 'x'])), 0..12), damage in prop::collection::vec((0usize..4_000, any::<u8>()), 0..4)) {
let text = mutate_text(&editing_text(), cut, &edits);
let mut private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
for (at, b) in damage {
+10 -6
View File
@@ -10,9 +10,10 @@
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen};
use vectorcraft_color::Paint;
use vectorcraft_doc::{CharStyle, Justify, NodeKind, ParaStyle, TextKind, TextObject, TextRun};
use vectorcraft_geom::PathData;
use vectorcraft_geom::AnchorKind;
use super::super::obj::V;
use super::art::path_data;
use super::{FLIP, GState, Reader};
use crate::latin;
@@ -32,8 +33,8 @@ pub(super) struct LegacyText {
start: f64,
/// Between `Tp` and `TP`: the path read is the text path.
in_path: bool,
/// The frame or path, in art space.
path: Option<BezPath>,
/// The frame or path, in art space, and the kind of the anchor each of its segments ends on.
path: Option<(BezPath, Vec<AnchorKind>)>,
runs: Vec<TextRun>,
/// Each paragraph's attributes, as they were at its first character.
paras: Vec<ParaStyle>,
@@ -154,12 +155,13 @@ impl Reader<'_> {
/// The path painted (or `TP`, ending the text path): the text object's frame or path.
pub(super) fn text_path(&mut self) {
let path = std::mem::take(&mut self.path);
let kinds = std::mem::take(&mut self.path_kinds);
self.end_path_quietly();
if let Some(t) = self.text.as_mut()
&& t.path.is_none()
&& !path.elements().is_empty()
{
t.path = Some(path);
t.path = Some((path, kinds));
}
}
@@ -364,8 +366,10 @@ impl Reader<'_> {
// Art space → text space (y down).
let to_text = FLIP * m.inverse();
let kind = match (t.kind, &t.path) {
(1, Some(p)) => TextKind::Area { frame: PathData::from_bezpath(&(to_text * p.clone())) },
(2, Some(p)) => TextKind::OnPath { path: PathData::from_bezpath(&(to_text * p.clone())), start: start_fraction(p, t.start), end: None },
(1, Some((p, kinds))) => TextKind::Area { frame: path_data(&(to_text * p.clone()), kinds) },
(2, Some((p, kinds))) => {
TextKind::OnPath { path: path_data(&(to_text * p.clone()), kinds), start: start_fraction(p, t.start), end: None }
}
_ => TextKind::Point,
};
let mut obj = TextObject::point(kurbo::Point::ZERO, "", CharStyle::default());
+8 -2
View File
@@ -12,7 +12,9 @@
//! `*W` (version 7 specification §15.4.2) is a group whose objects other than its type wrap
//! area type, with no offset.
//! - Paths: `m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, painted by `N n F f S s B b` (lower case
//! closes), `(op) *` for a guide. Stroke `w J j M d`, fill rule `XR`, overprint `O`/`R`.
//! closes), `(op) *` for a guide. A segment's operator in lower case makes the anchor it ends on
//! smooth, in upper case a corner; a closed path's first anchor takes the kind of the segment
//! back to it. Stroke `w J j M d`, fill rule `XR`, overprint `O`/`R`.
//! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`.
//! - Names: an `/ArtDictionary` after an object, its `AI10_ArtUID` the name's XML id.
//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`, else
@@ -65,7 +67,7 @@ use vectorcraft_doc::{
Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin,
LinkInfo, Node, NodeId, NodeKind, OpacityMask, PatternDef, StrokeLayer, Unit,
};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_geom::{AnchorKind, FillRule, PathData};
use super::super::ate::CANVAS_CENTRE;
use super::lex::{Lexer, Tok};
@@ -147,6 +149,9 @@ struct Reader<'a> {
gs: GState,
frames: Vec<Frame>,
path: BezPath,
/// The kind of the anchor each segment of `path` ends on, in order: smooth for a lower-case
/// segment operator, corner for an upper-case one (version 7 specification §5.3.2).
path_kinds: Vec<AnchorKind>,
points: usize,
/// The path is a clipping path (`W`).
clip_next: bool,
@@ -294,6 +299,7 @@ impl<'a> Reader<'a> {
gs: GState::default(),
frames: Vec::new(),
path: BezPath::new(),
path_kinds: Vec::new(),
points: 0,
clip_next: false,
doc,
+86 -17
View File
@@ -5,8 +5,8 @@ use super::types::{
NON_NATIVE_ART_UNREAD, Place,
};
use super::{
Affine, Appearance, AppearanceItem, Arc, BezPath, CANVAS_CENTRE, FillLayer, ImageBlob, ImageObject, Inks, Instance, LinkInfo, Node, NodeId,
NodeKind, Obj, OpacityMask, Paint, PathData, PathEl, Point, Reader, Rect, StrokeLayer, V, ps,
Affine, AnchorKind, Appearance, AppearanceItem, Arc, BezPath, CANVAS_CENTRE, FillLayer, ImageBlob, ImageObject, Inks, Instance, LinkInfo, Node,
NodeId, NodeKind, Obj, OpacityMask, Paint, PathData, PathEl, Point, Reader, Rect, StrokeLayer, V, ps,
};
impl<'a> Reader<'a> {
@@ -111,32 +111,45 @@ impl<'a> Reader<'a> {
return Err("a path has too many points".into());
}
let current = self.path.elements().last().and_then(|e| e.end_point());
match w {
let kind = if w.starts_with(|c: char| c.is_ascii_lowercase()) { AnchorKind::Smooth } else { AnchorKind::Corner };
let added = match w {
"m" => {
let Some(a) = p(2) else { return Ok(()) };
self.path.move_to(a);
return Ok(());
}
_ if current.is_none() => {}
"l" | "L" => {
if let Some(a) = p(2) {
_ if current.is_none() => false,
"l" | "L" => match p(2) {
Some(a) => {
self.path.line_to(a);
true
}
}
"c" | "C" => {
if let (Some(a), Some(b), Some(c)) = (p(6), p(4), p(2)) {
None => false,
},
"c" | "C" => match (p(6), p(4), p(2)) {
(Some(a), Some(b), Some(c)) => {
self.path.curve_to(a, b, c);
true
}
}
"v" | "V" => {
if let (Some(cur), Some(b), Some(c)) = (current, p(4), p(2)) {
_ => false,
},
"v" | "V" => match (current, p(4), p(2)) {
(Some(cur), Some(b), Some(c)) => {
self.path.curve_to(cur, b, c);
true
}
}
_ => {
if let (Some(a), Some(c)) = (p(4), p(2)) {
_ => false,
},
_ => match (p(4), p(2)) {
(Some(a), Some(c)) => {
self.path.curve_to(a, c, c);
true
}
}
_ => false,
},
};
if added {
self.path_kinds.push(kind);
}
Ok(())
}
@@ -144,6 +157,7 @@ impl<'a> Reader<'a> {
/// A path left unpainted before a container opens.
pub(crate) fn end_path_quietly(&mut self) {
self.path = BezPath::new();
self.path_kinds.clear();
self.points = 0;
self.clip_next = false;
}
@@ -151,6 +165,7 @@ impl<'a> Reader<'a> {
/// Paint the current path (`op`), as a guide when `guide`.
pub(super) fn paint(&mut self, op: &str, hidden: bool, guide: bool) -> Result<(), String> {
let mut bp = std::mem::take(&mut self.path);
let kinds = std::mem::take(&mut self.path_kinds);
self.points = 0;
let clip = std::mem::take(&mut self.clip_next);
if let Some(i) = self.instance.take() {
@@ -168,7 +183,7 @@ impl<'a> Reader<'a> {
_ => None,
};
let to_doc = self.space();
let path = PathData::from_bezpath(&(to_doc * bp));
let path = path_data(&(to_doc * bp), &kinds);
let gs = self.gs.clone();
let (fill, stroke) = match op.to_ascii_lowercase().as_str() {
"f" => (true, false),
@@ -593,6 +608,60 @@ impl<'a> Reader<'a> {
}
}
/// `bp` as a path whose anchors take the kinds in `kinds`, one for each segment of `bp` in order,
/// for the anchor the segment ends on. A closed subpath's last segment ends on its first anchor
/// when it returns to the start point, and that anchor takes its kind. Anchors no segment ends on
/// (a subpath's start) keep the kind [`PathData::from_bezpath`] gives them, and so does a subpath
/// whose anchors don't match its segments.
pub(super) fn path_data(bp: &BezPath, kinds: &[AnchorKind]) -> PathData {
let mut path = PathData::from_bezpath(bp);
// Each subpath's anchors as `from_bezpath` starts them: one for its start, one for each segment.
let mut subpaths: Vec<Vec<Option<AnchorKind>>> = Vec::new();
let mut kinds = kinds.iter().copied();
let mut open = false;
for el in bp.elements() {
match el {
PathEl::MoveTo(_) => {
subpaths.push(vec![None]);
open = true;
}
PathEl::ClosePath => open = false,
_ => {
let kind = kinds.next();
match subpaths.last_mut() {
Some(s) if open => s.push(kind),
_ => {
subpaths.push(vec![None, kind]);
open = true;
}
}
}
}
}
if subpaths.len() != path.subpaths.len() {
return path;
}
for (sp, mut set) in path.subpaths.iter_mut().zip(subpaths) {
// The closing anchor merged into the first: its kind goes to the first.
if sp.closed
&& set.len() == sp.anchors.len() + 1
&& let Some(last) = set.pop()
&& let Some(first) = set.first_mut()
{
*first = last;
}
if set.len() != sp.anchors.len() {
continue;
}
for (a, kind) in sp.anchors.iter_mut().zip(set) {
if let Some(kind) = kind {
a.kind = kind;
}
}
}
path
}
/// The name of the XML node `o` (its `xmlnode-nodename`) without a namespace prefix (`v:`).
pub(super) fn element_name(o: &Obj) -> Option<String> {
let name = o.text("xmlnode-nodename")?;
+89
View File
@@ -261,6 +261,95 @@ fn paths_colours_and_strokes() {
assert!(a[4].appearance.fill().is_none() && a[4].appearance.stroke().is_some());
}
/// Each subpath's anchor kinds, in order.
fn anchor_kinds(n: &Node) -> Vec<Vec<vectorcraft_geom::AnchorKind>> {
let Some(path) = n.path_data() else { panic!("{:?}", n.kind) };
path.subpaths.iter().map(|s| s.anchors.iter().map(|a| a.kind).collect()).collect()
}
/// A path's anchors at (x, y) in document space, and their handles.
fn anchor_points(n: &Node) -> Vec<[(f64, f64); 3]> {
let Some(path) = n.path_data() else { panic!("{:?}", n.kind) };
path.subpaths.iter().flat_map(|s| s.anchors.iter()).map(|a| [(a.p.x, a.p.y), (a.h_in.x, a.h_in.y), (a.h_out.x, a.h_out.y)]).collect()
}
/// A segment operator in lower case makes the anchor it ends on smooth, in upper case a corner
/// (§5.3.2), whether the segment is straight or curved and whatever its handles: the points and
/// handles stay where they are.
#[test]
fn a_segment_operators_case_sets_the_kind_of_the_anchor_it_ends_on() {
use vectorcraft_geom::AnchorKind::{Corner, Smooth};
let doc = read_ok(&layer("0 0 m\n10 10 20 0 30 0 c\n40 10 50 0 60 0 C\nh\nS\n")).doc;
let a = art(&doc);
assert_eq!(anchor_kinds(&a[0]), [vec![Corner, Smooth, Corner]], "handles that aren't in line stay a smooth point's");
assert_eq!(
anchor_points(&a[0]),
[[(0.0, 100.0), (0.0, 100.0), (10.0, 90.0)], [(30.0, 100.0), (20.0, 100.0), (40.0, 90.0)], [(60.0, 100.0), (50.0, 100.0), (60.0, 100.0)]]
);
// `l`, `L`, `v`, `y`, `C` with handles in line (a corner all the same) and `c`, on an open path.
let doc = read_ok(&layer("0 0 m\n10 0 l\n20 0 L\n30 10 40 10 v\n50 10 60 10 y\n70 10 80 10 90 10 C\n100 10 110 0 110 0 c\nS\n")).doc;
let a = art(&doc);
assert_eq!(anchor_kinds(&a[0]), [vec![Corner, Smooth, Corner, Smooth, Smooth, Corner, Smooth]]);
assert_eq!(anchor_points(&a[0])[5], [(90.0, 90.0), (80.0, 90.0), (100.0, 90.0)]);
}
/// A closed path's first anchor takes the kind of the segment back to it; a path closed without
/// one keeps its first anchor's kind from its handles.
#[test]
fn a_closed_paths_start_takes_the_case_of_the_segment_back_to_it() {
use vectorcraft_geom::AnchorKind::{Corner, Smooth};
let start = |close: &str| {
let doc = read_ok(&layer(&format!("{close}s\n"))).doc;
anchor_kinds(&art(&doc)[0])
};
assert_eq!(start("0 0 m\n40 0 L\n40 40 L\n0 0 l\n"), [vec![Smooth, Corner, Corner]]);
assert_eq!(start("0 0 m\n40 0 l\n40 40 l\n0 0 L\n"), [vec![Corner, Smooth, Smooth]]);
// The start's handles are in line, so it would be smooth by them alone.
assert_eq!(start("0 0 m\n10 0 40 30 40 40 c\n40 50 -10 0 0 0 C\n"), [vec![Corner, Smooth]]);
assert_eq!(start("0 0 m\n10 0 40 30 40 40 C\n40 50 -10 0 0 0 c\n"), [vec![Smooth, Corner]]);
assert_eq!(start("0 0 m\n40 0 L\n40 40 l\nh\n"), [vec![Corner, Corner, Smooth]], "no segment back to the start");
assert_eq!(start("0 0 m\n40 0 L\n40 40 l\n"), [vec![Corner, Corner, Smooth]], "`s` closes it the same way");
}
/// Every `m` starts a subpath of its own, in a compound path's members and within one path.
#[test]
fn each_subpath_takes_the_case_of_its_own_segments() {
use vectorcraft_geom::AnchorKind::{Corner, Smooth};
let body = "*u\n0 0 m\n10 0 l\n10 10 L\nh\nf\n20 0 m\n30 0 L\n30 10 l\n20 0 l\nf\n*U\n\
0 50 m\n10 50 L\n10 60 l\nh\n20 50 m\n30 50 l\n30 60 L\n20 50 l\nf\n";
let a = art(&read_ok(&layer(body)).doc);
let NodeKind::Compound { children, .. } = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert_eq!(anchor_kinds(&children[0]), [vec![Corner, Smooth, Corner]]);
assert_eq!(anchor_kinds(&children[1]), [vec![Smooth, Corner, Smooth]]);
assert_eq!(anchor_kinds(&a[1]), [vec![Corner, Corner, Smooth], vec![Smooth, Smooth, Corner]]);
}
/// Guide paths and clipping paths keep the case of their segments too.
#[test]
fn guide_and_clipping_paths_take_the_case_of_their_segments() {
use vectorcraft_geom::AnchorKind::{Corner, Smooth};
let a = art(&read_ok(&layer("(N) *\n10 10 m\n50 10 l\n90 10 L\n(N) *\n")).doc);
assert!(matches!(a[0].kind, NodeKind::Path { guide: true, .. }));
assert_eq!(anchor_kinds(&a[0]), [vec![Corner, Smooth, Corner]]);
let clipped = format!("0 Ae\nq\n{}f\n0 0 m\n30 0 l\n30 30 L\n0 0 l\nh\nW\nn\nQ\n", rect_path(0.0, 0.0, 40.0, 40.0));
let a = art(&read_ok(&layer(&clipped)).doc);
let NodeKind::Group { children, clip: true } = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
assert_eq!(anchor_kinds(&children[0]), [vec![Smooth, Smooth, Corner]]);
}
/// Operands that don't make a segment leave no kind behind, so the kinds after them stay with
/// their own anchors.
#[test]
fn a_segment_that_isnt_read_leaves_the_kinds_in_step() {
use vectorcraft_geom::AnchorKind::{Corner, Smooth};
let a = art(&read_ok(&layer("10 c\n0 0 m\n5 l\n10 0 L\n(x) 20 y\n20 0 l\n30 0 L\nS\n")).doc);
assert_eq!(anchor_kinds(&a[0]), [vec![Corner, Corner, Smooth, Corner]]);
// A subpath of a lone `m` is a point of its own.
let a = art(&read_ok(&layer("5 5 m\n0 0 m\n10 0 l\n20 0 L\nS\n")).doc);
assert_eq!(anchor_kinds(&a[0]), [vec![Corner], vec![Corner, Smooth, Corner]]);
}
/// `Xk` and `XK` are global process colours: a name, a tint and whether the colour is RGB, as
/// `Xx` has them (#1025). They come in as global swatches that aren't inks, linked at their tint.
#[test]
@@ -188,6 +188,22 @@ fn an_illustrator_3_file_without_layers_opens_its_paths_on_one_layer() {
assert_eq!(art[1].geometric_bounds(), Some(Rect::new(20.0, 50.0, 60.0, 80.0)));
}
/// In an Illustrator 3 file the case of a segment's operator says whether the anchor it ends on is
/// smooth (lower case) or a corner (upper case).
#[test]
fn an_illustrator_3_files_anchors_are_smooth_or_corners_by_their_operators_case() {
use vectorcraft_geom::AnchorKind::{Corner, Smooth};
let src = PATHS_ONLY.replace("10 10 m\n100 10 L\n50 80 60 90 10 10 C\nn\n", "0 0 m\n10 10 20 0 30 0 c\n40 10 50 0 60 0 C\nh\nS\n");
let r = import(src.as_bytes()).unwrap();
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
let art = r.document.layers[0].children().unwrap();
let path = art[0].path_data().unwrap();
let kinds: Vec<_> = path.subpaths[0].anchors.iter().map(|a| a.kind).collect();
assert_eq!(kinds, [Corner, Smooth, Corner]);
let middle = path.subpaths[0].anchors[1];
assert_eq!([(middle.h_in.x, middle.h_in.y), (middle.h_out.x, middle.h_out.y)], [(20.0, 100.0), (40.0, 90.0)]);
}
/// The same program in a file that doesn't say it is in Illustrator's format is PostScript whose
/// operators the reader doesn't know: it is still refused.
#[test]
+66
View File
@@ -1068,6 +1068,72 @@ fn bulleted_and_numbered_lists_are_warned_as_left_out() {
}
}
/// A closed path whose anchors' kinds differ from what their handles suggest: a corner with
/// handles in line, a smooth point with handles that aren't, a smooth point without handles and a
/// corner.
fn mixed_kinds() -> vectorcraft_geom::SubPath {
use vectorcraft_geom::{Anchor, Point, SubPath};
let at = |p: (f64, f64), h_in: (f64, f64), h_out: (f64, f64), kind| Anchor {
p: Point::from(p),
h_in: Point::from(h_in),
h_out: Point::from(h_out),
kind,
};
SubPath::new(
vec![
at((10.0, 10.0), (0.0, 10.0), (20.0, 10.0), AnchorKind::Corner),
at((40.0, 40.0), (40.0, 30.0), (50.0, 50.0), AnchorKind::Smooth),
at((70.0, 10.0), (70.0, 10.0), (70.0, 10.0), AnchorKind::Smooth),
at((90.0, 10.0), (90.0, 10.0), (90.0, 10.0), AnchorKind::Corner),
],
true,
)
}
/// The case of each segment operator written reads back as the kind of the anchor it ends on,
/// the first anchor's from the segment back to it.
#[test]
fn anchor_kinds_read_back() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let n = shape(&mut doc, PathData::new(vec![mixed_kinds()]), red(), Paint::None);
doc.insert(Some(layer), usize::MAX, n).unwrap();
let read = crate::import(&write(&doc).bytes).unwrap().document;
let path = children(&read.layers[0])[0].path_data().unwrap();
let want: Vec<_> = mixed_kinds().anchors.iter().map(|a| a.kind).collect();
assert_eq!(path.subpaths[0].anchors.iter().map(|a| a.kind).collect::<Vec<_>>(), want, "{path:?}");
let points: Vec<_> = path.subpaths[0].anchors.iter().map(|a| (a.p, a.h_in, a.h_out)).collect();
let wrote: Vec<_> = mixed_kinds().anchors.iter().map(|a| (a.p, a.h_in, a.h_out)).collect();
assert_eq!(points, wrote);
}
/// An area's frame and the path type runs along keep their anchors' kinds too.
#[test]
fn text_paths_keep_their_anchor_kinds() {
use vectorcraft_doc::{TextKind, TextObject};
let mut doc = Document::new(300.0, 200.0);
let layer = doc.layers[0].id;
let mut area = TextObject::point(vectorcraft_geom::Point::ZERO, "Area", style(10.0, Paint::solid(Color::BLACK)));
area.kind = TextKind::Area { frame: PathData::new(vec![mixed_kinds()]) };
let mut along = mixed_kinds();
along.closed = false;
let mut on_path = TextObject::point(vectorcraft_geom::Point::ZERO, "Along", style(10.0, Paint::solid(Color::BLACK)));
on_path.kind = TextKind::OnPath { path: PathData::new(vec![along.clone()]), start: 0.0, end: None };
for t in [area, on_path] {
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
let imported = crate::import(&write(&doc).bytes).unwrap();
let found = texts(&imported.document);
assert_eq!(found.len(), 2, "{:?}", imported.warnings);
let kinds = |p: &PathData| p.subpaths[0].anchors.iter().map(|a| a.kind).collect::<Vec<_>>();
let TextKind::Area { frame } = &found[0].1.kind else { panic!("{:?}", found[0].1.kind) };
// The frame's anchors after its first, which its handles decide.
assert_eq!(kinds(frame).get(1..4), kinds(&PathData::new(vec![mixed_kinds()])).get(1..4), "{frame:?}");
let TextKind::OnPath { path, .. } = &found[1].1.kind else { panic!("{:?}", found[1].1.kind) };
assert_eq!(kinds(path).get(1..), kinds(&PathData::new(vec![along])).get(1..), "{path:?}");
}
#[test]
fn type_reads_back_as_text() {
use vectorcraft_doc::{Justify, TextKind};
+1
View File
@@ -1086,6 +1086,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points.
Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392).
`.ai` and EPS editing-data import keeps template layers, and opens canvas and artboard ruler guides as ruler guides on their layers; guide objects stay editable guide paths (#779; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
In a `.ai` or EPS opened from its editing data, and in a legacy `.ai`, each anchor is smooth or a corner as the case of the segment operator ending on it says (`l`, `c`, `v`, `y` smooth; `L`, `C`, `V`, `Y` corner), and a closed path's first anchor takes the case of the segment back to it; points and handles stay where they are. Paths, compound paths, clipping paths, guide paths, area type frames and type-on-a-path paths all keep it (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/legacy_ai/tests.rs`).
`.ai` and EPS editing-data import keeps each path's Attributes panel note (`%AI3_Note:`, `%%Note:`, more lines in `%AI5_NoteMore:`, up to 1,024 characters) and Show Center (`1 Ap`); a note or Show Center applies to every path after it until the next one, and a note before a group or compound path is not theirs (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`).
A `.ai` saved without PDF compatibility (its PDF part only a placeholder page) is placed and pasted from its editing data, as it opens: File › Place places the art of the layers that show on the first artboard (`file.place`'s `page` places another one), and Paste of it as PDF data (`clipboard.importPdf`) takes every object. `document.open` with `pages` naming every page of such a file opens it the same way (`crates/engine/src/cmd/fileio/tests_aiimport.rs`).
A `.ai`, or a PDF saved with Illustrator's editing data, whose PDF part can't be read (a stream the import refuses) opens from its editing data, with the PDF part's error as its first warning; File › Place places its first artboard the same way, and Paste of it as PDF data with `editingData: true` takes every object (part of #4366; `crates/engine/src/cmd/fileio/tests_aiimport.rs`).