.ai import: template layers and ruler guides open as they were set

The editing data's layer options set a template layer from the flag four
places after the colour index. The colour index of nine-operand options,
and the colour index and dimming percentage of eleven and twelve operand
options, are read from their own places (they were read one place later).

A straight (N) * guide path directly on a layer that runs across the
whole canvas, or from edge to edge of one artboard, opens as a ruler
guide on that layer (an artboard guide of that artboard); other guide
paths stay guide paths. Guides scale with the document when its editing
data is in the units of a UserUnit page, and an EPS that keeps only its
first artboard keeps only that artboard's guides.

Fixes #779
This commit is contained in:
dotCooCoo
2026-11-01 11:08:18 +07:00
committed by bflatastic
parent 469c7a421e
commit 70651f585e
12 changed files with 343 additions and 16 deletions
+55
View File
@@ -3011,6 +3011,22 @@ proptest! {
}
}
/// An operand of a layer's options (`Lb`): a flag, a colour index or component, a percentage, or
/// anything else.
fn arb_layer_operand() -> impl Strategy<Value = String> {
prop_oneof![Just("0".to_string()), Just("1".to_string()), (-2i32..300).prop_map(|v| v.to_string()), arb_num()]
}
/// A coordinate of a guide path: the ends of the canvas around the template box's centre (100 50),
/// the edges of the artboards, near them, or anything else.
fn arb_guide_coord() -> impl Strategy<Value = String> {
prop_oneof![
prop::sample::select(vec!["-8091.5", "8291.5", "-8141.5", "8241.5", "0", "50", "100", "200", "-8091.505", "8291.5001", "1e308"])
.prop_map(str::to_string),
arb_num(),
]
}
/// The operators, operands and section comments of hostile editing data.
fn arb_ai_token() -> impl Strategy<Value = String> {
prop_oneof![
@@ -3037,6 +3053,9 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"b",
"W",
"(b) *",
"(N) *",
"(S) *",
"-8091.5 50 m 8291.5 50 L (N) *",
"u",
"U",
"*u",
@@ -3297,6 +3316,42 @@ proptest! {
survive("hostile editing data in an EPS of its art's box", || vectorcraft_engine::cmd::fileio::load("x.eps", &boxed).ok().map(|l| l.doc))?;
}
/// Layer options (`Lb`) of any number of operands with any values, and guide paths (`(N) *`,
/// `(S) *`) of any points, among them ones across the whole canvas or from edge to edge of
/// artboards that overlap or are the same, on layers and sublayers.
#[test]
fn ai_layer_options_and_ruler_guides_never_panic(
options in prop::collection::vec(arb_layer_operand(), 0..17),
guides in prop::collection::vec((prop::collection::vec((arb_guide_coord(), arb_guide_coord()), 0..5), any::<bool>(), any::<bool>()), 0..12),
boards in prop::collection::vec(prop::sample::select(vec![[0.0, 100.0, 200.0, 0.0], [0.0, 100.0, 100.0, 0.0], [50.0, 100.0, 200.0, 0.0]]), 0..4),
) {
use vectorcraft_testkit::ai;
let board = |b: &[f64; 4]| {
format!(
"%_/Dictionary :\n%_{} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
b[0], b[1], b[2], b[3]
)
};
let array: String = boards.iter().map(board).collect();
let document = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{array}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n");
let paths: Vec<String> = guides
.iter()
.map(|(points, ruler, sub)| {
let ops: String = points.iter().enumerate().map(|(i, (x, y))| format!("{x} {y} {}\n", if i == 0 { "m" } else { "L" })).collect();
let path = format!("{ops}({}) *\n", if *ruler { "N" } else { "S" });
if *sub { format!("%AI5_BeginLayer\n{} Lb\n(Sub) Ln\n{path}LB\n%AI5_EndLayer--\n", options.join(" ")) } else { path }
})
.collect();
let body = format!("{document}%AI5_BeginLayer\n{} Lb\n(L) Ln\n{}\nLB\n%AI5_EndLayer--\n", options.join(" "), paths.concat());
let data = ai::editing_data(200.0, 100.0, &body).replacen("%%EndComments", "%AI3_TemplateBox: 100 50 100 50\n%%EndComments", 1);
survive("layer options and ruler guides", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
survive("layer options and ruler guides in units of 10 points", || {
Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(2000.0, 1000.0), vec![], false, false, 10.0).0)
})?;
let eps = ai::eps(data.as_bytes(), ai::page_ps());
survive("layer options and ruler guides in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
}
/// The testkit's sample damaged, as it is and compressed, in EPS, `.ai` and PDF files.
#[test]
fn ai_mutated_sample_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, any::<u8>()), 0..12)) {
+10 -3
View File
@@ -1,9 +1,12 @@
//! The reader: runs the editing data's operators into a document.
//!
//! - Art space has y up; the first artboard's top left corner is the document's origin.
//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b …
//! dimPercent … Lb` (ten operands in the legacy format, without the one before the colour
//! index), `(name) Ln`, its art, `LB`.
//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b
//! template dimPercent … Lb` (ten operands in the legacy format, without the one before the
//! colour index; see `Reader::layer_attrs`), `(name) Ln`, its art, `LB`.
//! - Ruler guides: a straight `(N) *` guide path, directly on a layer, that runs across the whole
//! canvas (16,383 points around the template box's centre) or from edge to edge of one artboard
//! is a ruler guide on that layer ([`Document::guides`]); other guide paths stay paths.
//! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted
//! with `W` clips); a group's transparency and name follow its end.
//! - Paths: `m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, painted by `N n F f S s B b` (lower case
@@ -206,6 +209,9 @@ struct Reader<'a> {
clip_fills: HashSet<NodeId>,
/// Groups closed as an object's drawn look (`9 () XW`).
looks: HashSet<NodeId>,
/// Straight `(N) *` guide paths painted directly on a layer, by id: their two points in art
/// space. [`Reader::finish`] makes the ruler guides among them guides of their layer.
ruler_guides: HashMap<NodeId, (Point, Point)>,
/// Legacy compound member groups, even when followed by the generic `9 () XW` marker.
compound_groups: HashSet<NodeId>,
/// Groups of a text object and its look (see [`Self::style_marker`]), which aren't a look.
@@ -308,6 +314,7 @@ impl<'a> Reader<'a> {
blend_budget: BLEND_BUDGET,
clip_fills: HashSet::new(),
looks: HashSet::new(),
ruler_guides: HashMap::new(),
compound_groups: HashSet::new(),
text_looks: HashSet::new(),
group_ended: false,
+20 -2
View File
@@ -1,11 +1,12 @@
use super::containers::close;
use super::document::TEXT_SLOT;
use super::types::{
FLIP, IMAGE_TOO_LARGE, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, Place,
FLIP, Frame, IMAGE_TOO_LARGE, Kind, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART,
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, Point, Reader, Rect, StrokeLayer, V, ps,
NodeKind, Obj, OpacityMask, Paint, PathData, PathEl, Point, Reader, Rect, StrokeLayer, V, ps,
};
impl<'a> Reader<'a> {
@@ -162,6 +163,10 @@ impl<'a> Reader<'a> {
if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) {
close(&mut bp);
}
let line = match bp.elements() {
[PathEl::MoveTo(a), PathEl::LineTo(b)] => Some((*a, *b)),
_ => None,
};
let to_doc = self.space();
let path = PathData::from_bezpath(&(to_doc * bp));
let gs = self.gs.clone();
@@ -191,6 +196,19 @@ impl<'a> Reader<'a> {
}
let kind = NodeKind::Path { path, rule: gs.rule, live: None, clipping: clip, guide };
let mut node = self.new_node(kind, &gs);
// A ruler guide is written as a straight `(N) *` guide path on its layer.
if guide
&& op == "N"
&& !hidden
&& !gs.hidden
&& !clip
&& self.pattern.is_none()
&& matches!(self.frames.last(), Some(Frame { kind: Kind::Layer(_), .. }))
&& let Some((a, b)) = line
&& [a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite())
{
self.ruler_guides.insert(node.id, (a, b));
}
node.appearance = Appearance { items, ..Appearance::default() }.into();
node.opacity = gs.opacity;
node.blend = gs.blend;
+1 -1
View File
@@ -222,7 +222,7 @@ impl<'a> Reader<'a> {
let color = a.color.unwrap_or(LayerColor::Preset(0));
let mut layer = Node::new(
self.doc.alloc_id(),
NodeKind::Layer { color, template: false, printable: a.printable, children, clip, preview: a.preview, dim_images: a.dim },
NodeKind::Layer { color, template: a.template, printable: a.printable, children, clip, preview: a.preview, dim_images: a.dim },
);
layer.name = Some(a.name.clone());
layer.visible = a.visible;
+77 -2
View File
@@ -1,8 +1,11 @@
use super::art::{element_name, elements, join_and, sane};
use super::containers::drop_commented_copies;
use super::types::{FLIP, Kind, MAX_DEPTH};
use super::{Affine, Artboard, ColorMode, Document, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure, Swatch, Unit, V, obj};
use vectorcraft_doc::{ImageBlob, PatternDef};
use super::{
Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure,
Swatch, Unit, V, obj,
};
use vectorcraft_doc::{Guide, ImageBlob, PatternDef};
/// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files).
fn ruler_unit(v: u8) -> Option<Unit> {
@@ -395,6 +398,7 @@ impl<'a> Reader<'a> {
} else {
self.artboards.clone()
};
let board_rects: Vec<Rect> = boards.iter().map(|(_, r)| *r).collect();
doc.artboards = boards
.into_iter()
.enumerate()
@@ -424,6 +428,22 @@ impl<'a> Reader<'a> {
color: None,
}];
}
if !self.ruler_guides.is_empty() {
let ids: Vec<u32> = doc.artboards.iter().map(|a| a.id).collect();
let (candidates, centre) = (std::mem::take(&mut self.ruler_guides), self.template_center);
let pick = |id: NodeId| -> Option<Guide> {
let (a, b) = candidates.get(&id)?;
let (vertical, board) = ruler_guide(*a, *b, centre, &board_rects)?;
let p = to_doc * *a;
let artboard = match board {
Some(i) => Some(*ids.get(i)?),
None => None,
};
let pos = if vertical { p.x } else { p.y };
pos.is_finite().then_some(Guide { vertical, pos, artboard, layer: None })
};
take_ruler_guides(&mut doc.layers, &pick, &mut doc.guides, 0);
}
if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) {
doc.color_mode = ColorMode::Cmyk;
let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
@@ -474,6 +494,61 @@ impl<'a> Reader<'a> {
}
/// The dictionary of entry `key` in `o` or the dictionaries it holds.
/// How far (points) a ruler guide's ends may lie from the canvas's or an artboard's edges.
const GUIDE_TOLERANCE: f64 = 0.01;
/// Whether the straight guide path from `a` to `b` (art space) is a ruler guide → whether it is
/// vertical, and the index in `boards` of the artboard it belongs to (`None`: a canvas guide). A
/// canvas guide runs across the whole canvas, 16,383 points centred on the template box's
/// centre `centre`; an artboard guide runs from edge to edge of one artboard of `boards`, inside it.
fn ruler_guide(a: Point, b: Point, centre: Option<(f64, f64)>, boards: &[Rect]) -> Option<(bool, Option<usize>)> {
let near = |u: f64, v: f64| (u - v).abs() <= GUIDE_TOLERANCE;
let vertical = match (near(a.x, b.x), near(a.y, b.y)) {
(true, false) => true,
(false, true) => false,
_ => return None,
};
let (along, across) = if vertical { ((a.y.min(b.y), a.y.max(b.y)), a.x) } else { ((a.x.min(b.x), a.x.max(b.x)), a.y) };
if let Some((cx, cy)) = centre {
let c = if vertical { cy } else { cx };
if near(along.0, c - CANVAS_CENTRE) && near(along.1, c + CANVAS_CENTRE) {
return Some((vertical, None));
}
}
let mut on = boards.iter().enumerate().filter(|(_, r)| {
let (ends, sides) =
if vertical { ((r.min_y(), r.max_y()), (r.min_x(), r.max_x())) } else { ((r.min_x(), r.max_x()), (r.min_y(), r.max_y())) };
near(along.0, ends.0) && near(along.1, ends.1) && (sides.0..=sides.1).contains(&across)
});
match (on.next(), on.next()) {
(Some((i, _)), None) => Some((vertical, Some(i))),
_ => None,
}
}
/// Moves the children of the layers and sublayers of `nodes` that `pick` makes a guide of into
/// `guides`, each on its layer.
fn take_ruler_guides(nodes: &mut [Arc<Node>], pick: &dyn Fn(NodeId) -> Option<Guide>, guides: &mut Vec<Guide>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for n in nodes {
if !matches!(n.kind, NodeKind::Layer { .. }) {
continue;
}
let id = n.id;
let Some(kids) = Arc::make_mut(n).children_mut() else { continue };
kids.retain(|k| match pick(k.id) {
Some(g) => {
guides.push(Guide { layer: Some(id), ..g });
false
}
None => true,
});
take_ruler_guides(kids, pick, guides, depth + 1);
}
}
fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> {
if depth > 8 {
return None;
+8 -4
View File
@@ -308,8 +308,11 @@ impl<'a> Reader<'a> {
Ok(())
}
/// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place
/// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`).
/// `… Lb`: a layer's options, `visible preview unlocked printing dimmed`, then the colour index
/// and its RGB. The colour index is at 5 with fewer than ten operands, 6 with ten to twelve
/// (`masks` before it) and 7 with thirteen or more (one more flag before it). Four places after
/// the colour index comes the template flag (eleven operands or more), then the dimming
/// percentage (twelve or more, else 50). A fourteenth operand changes nothing here.
fn layer_attrs(&mut self, vals: &[V]) {
let n = Self::nums(vals);
let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return };
@@ -318,9 +321,10 @@ impl<'a> Reader<'a> {
a.preview = flag(1, true);
a.locked = !flag(2, true);
a.printable = flag(3, true);
let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0);
let at = 5 + usize::from(n.len() >= 10) + usize::from(n.len() >= 13);
a.template = flag(at + 4, false);
let dim_pct = n.get(at + 5).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0);
a.dim = flag(4, false).then_some(dim_pct.round() as u8);
let at = if n.len() <= 10 { 6 } else { 7 };
let index = n.get(at).copied().unwrap_or(0.0);
let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8);
a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 {
+1
View File
@@ -123,6 +123,7 @@ pub(super) struct LayerAttrs {
pub(super) preview: bool,
pub(super) locked: bool,
pub(super) printable: bool,
pub(super) template: bool,
pub(super) dim: Option<u8>,
pub(super) color: Option<LayerColor>,
}
+117 -1
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use kurbo::{Point, Rect};
use vectorcraft_color::gradient::GradientKind;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit};
use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Guide, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit};
use super::*;
@@ -2133,6 +2133,122 @@ fn stroked_path_guides_from_the_star_operator() {
assert!(matches!(n.appearance.items[0], AppearanceItem::Stroke(_)));
}
/// [`stream`] with a template box centred on (306.5, 395.5): the canvas runs 8,191.5 points each
/// way from there.
fn canvas_stream(body: &str) -> String {
stream(body).replacen("%%EndComments", "%AI3_TemplateBox: 306.5 395.5 306.5 395.5\n%%EndComments", 1)
}
/// The straight guide path from (`x0`, `y0`) to (`x1`, `y1`), `(op) *`.
fn guide_line(x0: f64, y0: f64, x1: f64, y1: f64, op: &str) -> String {
format!("{x0} {y0} m\n{x1} {y1} L\n({op}) *\n")
}
/// #779: a straight `(N) *` guide path across the whole canvas, directly on a layer or sublayer, is
/// a ruler guide on that layer, at its place in the document; it leaves no path behind.
#[test]
fn canvas_wide_guide_paths_are_ruler_guides_of_their_layer() {
let body = format!(
"{}{}{RED}{}f\n%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Sub) Ln\n{}LB\n%AI5_EndLayer--\n",
guide_line(-7885.0, 40.0, 8498.0, 40.0, "N"),
guide_line(25.0, 8587.0, 25.0, -7796.0, "N"),
rect_path(0.0, 0.0, 10.0, 10.0),
guide_line(-7885.0, 90.0, 8498.0, 90.0, "N"),
);
let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc;
let top = &doc.layers[0];
let kids = top.children().unwrap();
assert_eq!(kids.len(), 2, "the square and the sublayer");
assert!(matches!(kids[0].kind, NodeKind::Path { guide: false, .. }));
let sub = &kids[1];
assert!(sub.children().unwrap().is_empty());
let want = [
Guide { vertical: false, pos: 60.0, artboard: None, layer: Some(top.id) },
Guide { vertical: true, pos: 25.0, artboard: None, layer: Some(top.id) },
Guide { vertical: false, pos: 10.0, artboard: None, layer: Some(sub.id) },
];
assert_eq!(doc.guides, want);
}
/// #779: a straight `(N) *` guide path from edge to edge of one artboard is a ruler guide of that
/// artboard.
#[test]
fn artboard_wide_guide_paths_are_ruler_guides_of_their_artboard() {
// The crop marks' artboard, 0 0 200 100.
let doc = read(canvas_stream(&layer(&guide_line(0.0, 30.0, 200.0, 30.0, "N"))).as_bytes()).unwrap().doc;
assert_eq!(doc.guides, [Guide { vertical: false, pos: 70.0, artboard: Some(1), layer: Some(doc.layers[0].id) }]);
assert!(art(&doc).is_empty());
// The second of two artboards: art space x 400 to 600, y -150 to 0.
let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
%_/Dictionary :\n%_0 200 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_300 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_/Dictionary :\n%_400 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_600 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
let doc = read_ok(&format!("{boards}{}", layer(&guide_line(500.0, 0.0, 500.0, -150.0, "N")))).doc;
assert_eq!(doc.artboards[1].id, 2);
assert_eq!(doc.guides, [Guide { vertical: true, pos: 500.0, artboard: Some(2), layer: Some(doc.layers[0].id) }]);
}
/// Guide paths that aren't ruler guides stay guide paths: `(S) *` ones (guides made from objects),
/// even across the whole canvas or an artboard, short ones, slanted ones, and ones in a group.
#[test]
fn other_guide_paths_stay_guide_paths() {
let cases = [
guide_line(-7885.0, 40.0, 8498.0, 40.0, "S"),
guide_line(20.0, 40.0, 80.0, 40.0, "S"),
guide_line(20.0, 40.0, 80.0, 40.0, "N"),
guide_line(0.0, 30.0, 200.0, 30.0, "S"),
guide_line(-7885.0, -7796.0, 8498.0, 8587.0, "N"),
format!("u\n{}U\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")),
format!("1 Xw\n{}0 Xw\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")),
"-7885 40 m\n306 40 L\n8498 40 L\n(N) *\n".to_string(),
];
for body in cases {
let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc;
assert!(doc.guides.is_empty(), "{body}");
assert_eq!(art(&doc).len(), 1, "{body}");
}
}
/// The layer whose `Lb` has `ops` as its options (the first one in the file).
fn layer_with(ops: &str) -> Node {
let doc = read_ok(&format!("%AI5_BeginLayer\n{ops} Lb\n(L) Ln\nLB\n%AI5_EndLayer--\n")).doc;
Node::clone(&doc.layers[0])
}
/// #779: the template flag four places after the colour index makes a template layer; locking,
/// printing and dimming keep their own flags. In thirteen and fourteen operands the flag before
/// the colour index and the last one change nothing.
#[test]
fn the_template_flag_of_a_layer() {
let l = layer_with("1 1 0 0 1 0 0 4 255 79 255 1 50 0");
let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() };
assert!(template && !printable && l.locked);
assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(4)));
for ops in ["1 1 1 1 1 0 1 5 1 2 3 1 30 0", "1 1 1 1 1 0 0 5 1 2 3 1 30 1", "1 1 1 1 1 0 1 5 1 2 3 1 30"] {
let l = layer_with(ops);
let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() };
assert!(template && printable && !l.locked, "{ops}");
assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(5)), "{ops}");
}
let l = layer_with("1 1 1 1 0 0 1 0 79 128 255 0 50 0");
assert!(matches!(l.kind, NodeKind::Layer { template: false, .. }));
}
/// The colour index is one place earlier in eleven and twelve operands, as in ten, and the twelfth
/// is the dimming percentage; with nine it is one place earlier still.
#[test]
fn layer_options_of_nine_to_twelve_operands() {
let options = |l: Node| match l.kind {
NodeKind::Layer { template, dim_images, color, .. } => (template, dim_images, color),
_ => panic!(),
};
assert_eq!(options(layer_with("1 1 1 1 1 0 3 1 2 3 1")), (true, Some(50), LayerColor::Preset(3)));
assert_eq!(options(layer_with("1 1 1 1 1 0 3 1 2 3 0 25")), (false, Some(25), LayerColor::Preset(3)));
assert_eq!(options(layer_with("1 1 1 1 1 0 -1 10 20 30 1 25")), (true, Some(25), LayerColor::Custom([10, 20, 30])));
assert_eq!(options(layer_with("1 1 1 1 0 0 3 1 2 3")), (false, None, LayerColor::Preset(3)));
assert_eq!(options(layer_with("1 1 1 1 0 2 1 2 3")), (false, None, LayerColor::Preset(2)));
}
/// A blend's record as the editing data writes it after `(Adobe Path Blends) 1 0 n XP`: `DCBA`,
/// version 1, then (as far as the reader reads it) the number of keys at word 10 and a record of
/// six words per key starting with its id. Hand-written; the other words are zero.
+12 -2
View File
@@ -190,6 +190,7 @@ pub(super) fn build_from(
if let Some(a) = doc.artboards.first_mut() {
a.rect = rect;
}
drop_guides_off_boards(&mut doc);
}
frame = Some(Frame { page: visible, page_rect, rect, whole, pages: page_moves });
}
@@ -933,8 +934,14 @@ fn page_boards<'a>(boards: &'a [vectorcraft_doc::Artboard], first: usize, pages:
((first > 0 || pages.len() == boards.len()) && shown.iter().zip(pages).all(same)).then_some(shown)
}
/// Draw `doc`'s layers and artboards `k` times as large, from the origin, with their strokes,
/// effects and pattern tiles.
/// Drops the guides of artboards `doc` no longer has: an artboard guide goes with its artboard.
fn drop_guides_off_boards(doc: &mut Document) {
let kept: Vec<u32> = doc.artboards.iter().map(|a| a.id).collect();
doc.guides.retain(|g| g.artboard.is_none_or(|id| kept.contains(&id)));
}
/// Draw `doc`'s layers, artboards and guides `k` times as large, from the origin, with their
/// strokes, effects and pattern tiles.
fn scale_document(doc: &mut Document, k: f64) {
let a = Affine::scale(k);
let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() };
@@ -944,6 +951,9 @@ fn scale_document(doc: &mut Document, k: f64) {
for board in &mut doc.artboards {
board.rect = a.transform_rect_bbox(board.rect);
}
for guide in &mut doc.guides {
guide.pos *= k;
}
}
/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's
+40
View File
@@ -1135,6 +1135,46 @@ fn editing_data_in_the_units_of_a_user_unit_page_comes_in_at_the_pages_size() {
assert!(near(first(&d, 0), [100.0, 860.0, 140.0, 900.0]), "{:?}", first(&d, 0));
}
/// #779: a `.ai`'s ruler guides come in as guides of their layers, at the page's size when the
/// editing data is in the units of a `UserUnit` page.
#[test]
fn an_ai_files_ruler_guides_come_in_on_their_layers() {
// Across the canvas (16,383 points around the template box's centre, 50 50) at y 40, and
// across the artboard at x 20.
let guides = "-8141.5 40 m\n8241.5 40 L\n(N) *\n20 0 m\n20 100 L\n(N) *\n";
let art = native(&layer("Jig", true, &format!("{}\n{guides}", square(10, 10))));
let art = art.replacen("%AI5_NumLayers", "%AI3_TemplateBox: 50 50 50 50\n%AI5_NumLayers", 1);
for k in [1.0, 10.0] {
let page = in_points(pdf_part(10.0, 86.0), k);
let (d, w, read) = crate::layered_ai(&private(&art), page, vec![], false, false, k);
assert!(read, "{w:?}");
let jig = d.layers[0].id;
let want = [
vectorcraft_doc::Guide { vertical: false, pos: 60.0 * k, artboard: None, layer: Some(jig) },
vectorcraft_doc::Guide { vertical: true, pos: 20.0 * k, artboard: Some(d.artboards[0].id), layer: Some(jig) },
];
assert_eq!(d.guides, want);
assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 1, "the square alone");
}
}
/// An EPS whose page is its art's box keeps only its first artboard, and the guides of that one:
/// the ruler guides of the artboards left out go with them.
#[test]
fn an_eps_of_its_art_box_keeps_the_guides_of_its_first_artboard() {
let point = |x: u32, y: u32, n: u8| format!("%_{x} {y} /RealPointRelToROrigin\n%_ (PositionPoint{n}) ,\n");
let board = |x0: u32, x1: u32| format!("%_/Dictionary :\n{}{}%_; ,\n", point(x0, 50, 1), point(x1, 0, 2));
let array = format!("%_/Array :\n{}{}%_; (ArtboardArray) ,\n", board(0, 50), board(50, 100));
let boards = format!("%_/Document :\n%_/Dictionary :\n{array}%_; /NotRecorded ,\n%_;\n");
let guides = "25 0 m\n25 50 L\n(N) *\n75 0 m\n75 50 L\n(N) *\n";
let art = native(&format!("{boards}{}", layer("Art", true, &format!("{}\n{guides}", square(10, 10)))));
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert_eq!(r.document.artboards.len(), 1);
let (board, layer) = (r.document.artboards[0].id, r.document.layers[0].id);
assert_eq!(r.document.guides, [vectorcraft_doc::Guide { vertical: true, pos: 25.0, artboard: Some(board), layer: Some(layer) }]);
}
/// A PDF part read from a `UserUnit` page cropped to the art: the art's box, not the artboard, is
/// the page in the editing data's units, so the layers are read at the page's size, with their
/// artboard.
+1 -1
View File
@@ -92,7 +92,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|---|---|---|---|---|
| VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778); a dynamic symbol's type (`dynamic` on the symbol) and an instance's appearance changes to its art (`symbol_overrides`, each path at most 64 deep, counted against the object budget) are written only when set, so documents without them save as before (#4938) |
| Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) |
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus |
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus |
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160) |
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
| PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` |
+1
View File
@@ -1069,6 +1069,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
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`).
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`).
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`).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 and planar files with extra channels are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
- The Open and Place dialogs list PostScript `.ps` files and `.epsf` files. A `.ps` file opens and places through the EPS reader (its first page), also when its header is only `%!`, as the `ps` format, which exports don't write (Print to PostScript does); an `.epsf` file opens and places as EPS. Copied in a file manager, these files paste as EPS, and `clipboard.importEps` also reads PostScript whose header is only `%!` (`crates/engine/src/cmd/fileio/tests_epsimport.rs`).