diff --git a/apps/vectorcraft/src/windows_stylus.rs b/apps/vectorcraft/src/windows_stylus.rs index b18bcddae..5e996c4bd 100644 --- a/apps/vectorcraft/src/windows_stylus.rs +++ b/apps/vectorcraft/src/windows_stylus.rs @@ -16,6 +16,14 @@ //! mouse message that Windows did not make from a pen or a touch //! () //! clears the feed. +//! +//! The same `POINTER_PEN_INFO` gives the pen's tilt and barrel rotation (#372: a 6D Art Pen turns a +//! Calligraphic brush's nib with its barrel), each only where its `PEN_MASK_*` bit says the pen +//! reports it (): +//! `tiltX` and `tiltY` in degrees −90..90, positive to the right and toward the user, and +//! `rotation` in degrees 0..359 clockwise +//! (). +//! Toward the user is down the screen, so they are [`PenPose`]'s axes and direction as they come. #![cfg_attr(not(windows), allow(dead_code))] use std::cell::RefCell; @@ -37,6 +45,11 @@ const PEN_FLAG_INVERTED: u32 = 0x0002; const PEN_FLAG_ERASER: u32 = 0x0004; /// `PEN_MASK_PRESSURE` (`winuser.h`), in `POINTER_PEN_INFO::penMask`: the pen reports pressure. const PEN_MASK_PRESSURE: u32 = 0x0001; +/// `PEN_MASK_ROTATION`, `PEN_MASK_TILT_X` and `PEN_MASK_TILT_Y` (`winuser.h`): the pen reports its +/// barrel rotation, its tilt along x and its tilt along y. +const PEN_MASK_ROTATION: u32 = 0x0002; +const PEN_MASK_TILT_X: u32 = 0x0004; +const PEN_MASK_TILT_Y: u32 = 0x0008; /// `MI_WP_SIGNATURE` and `SIGNATURE_MASK`: the extra information of a mouse message that Windows /// made from a pen or a touch. const MI_WP_SIGNATURE: u32 = 0xFF51_5700; @@ -94,7 +107,8 @@ pub fn msg_hook(msg: *const std::ffi::c_void) -> bool { // pointer that is not a pen. unsafe { GetPointerPenInfo((msg.wParam.0 & 0xffff) as u32, &mut info) }.ok()?; let in_contact = info.pointerInfo.pointerFlags.0 & POINTER_FLAG_INCONTACT.0 != 0; - Some(pen_sample(info.pressure, info.penMask, info.penFlags, in_contact)) + let pose = pen_pose(info.penMask, info.tiltX, info.tiltY, info.rotation); + Some(pen_sample(info.pressure, info.penMask, info.penFlags, in_contact, pose)) }; // SAFETY: `GetMessageExtraInfo` reads the extra information of the thread's last message. let from_mouse = || is_mouse(unsafe { GetMessageExtraInfo() }.0 as u32); @@ -106,15 +120,25 @@ pub fn msg_hook(msg: *const std::ffi::c_void) -> bool { /// What a pen whose `POINTER_PEN_INFO` holds `pressure` (0..1024), `pen_mask` and `pen_flags` /// reports: the pressure as winit gives it to a touch, 0 while it hovers, a full press for a pen -/// in contact that reports none; and the eraser end while it is inverted or its eraser button is -/// pressed. -fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool) -> PenSample { +/// in contact that reports none; the eraser end while it is inverted or its eraser button is +/// pressed; and how it is held, `pose` ([`pen_pose`]). +fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool, pose: PenPose) -> PenSample { let pressure = match pressure { 1..=1024 if pen_mask & PEN_MASK_PRESSURE != 0 => pressure as f32 / 1024.0, _ if in_contact => 1.0, _ => 0.0, }; - PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0, pose: PenPose::default() } + PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0, pose } +} + +/// How a pen whose `POINTER_PEN_INFO` holds `pen_mask`, `tilt_x`, `tilt_y` and `rotation` is +/// held: the tilt when the pen reports it along either axis (0 along an axis it doesn't), the +/// rotation when it reports one, each kept within [`PenPose`]'s ranges. +fn pen_pose(pen_mask: u32, tilt_x: i32, tilt_y: i32, rotation: u32) -> PenPose { + let along = |mask: u32, t: i32| if pen_mask & mask != 0 { t as f32 } else { 0.0 }; + let tilt = (pen_mask & (PEN_MASK_TILT_X | PEN_MASK_TILT_Y) != 0).then(|| [along(PEN_MASK_TILT_X, tilt_x), along(PEN_MASK_TILT_Y, tilt_y)]); + let rotation = (pen_mask & PEN_MASK_ROTATION != 0).then_some(rotation as f32); + PenPose { tilt, rotation }.sanitized() } /// Whether a mouse message with this extra information (`GetMessageExtraInfo`) comes from a mouse, @@ -390,7 +414,7 @@ mod tests { #[test] fn an_inverted_pen_or_its_eraser_button_is_the_eraser_end() { - let eraser = |flags| pen_sample(512, PEN_MASK_PRESSURE, flags, true).eraser; + let eraser = |flags| pen_sample(512, PEN_MASK_PRESSURE, flags, true, PenPose::default()).eraser; assert!(!eraser(0), "the tip"); assert!(!eraser(PEN_FLAG_BARREL), "the barrel button"); assert!(eraser(PEN_FLAG_INVERTED), "turned over"); @@ -399,19 +423,32 @@ mod tests { assert!(eraser(PEN_FLAG_INVERTED | PEN_FLAG_BARREL)); } + #[test] + fn a_pens_tilt_and_rotation_come_where_it_reports_them() { + let all = PEN_MASK_TILT_X | PEN_MASK_TILT_Y | PEN_MASK_ROTATION; + assert_eq!(pen_pose(all, 30, -45, 90), PenPose { tilt: Some([30.0, -45.0]), rotation: Some(90.0) }); + assert_eq!(pen_pose(PEN_MASK_PRESSURE, 30, -45, 90), PenPose::default(), "a pen that reports neither"); + assert_eq!(pen_pose(PEN_MASK_TILT_X, 30, -45, 90), PenPose { tilt: Some([30.0, 0.0]), rotation: None }, "one axis"); + assert_eq!(pen_pose(PEN_MASK_ROTATION, 0, 0, 359), PenPose { tilt: None, rotation: Some(359.0) }, "a 6D Art Pen's barrel"); + let wild = pen_pose(all, 400, i32::MIN, 720); + assert_eq!(wild, PenPose { tilt: Some([90.0, -90.0]), rotation: Some(0.0) }, "kept within the ranges"); + let pose = pen_pose(all, 10, 20, 180); + assert_eq!(pen_sample(512, PEN_MASK_PRESSURE | all, 0, true, pose).pose, pose); + } + #[test] fn a_pens_pressure_is_the_one_winit_gives_its_touch() { - assert_eq!(pen_sample(512, PEN_MASK_PRESSURE, 0, true).pressure, 0.5); - assert_eq!(pen_sample(1024, PEN_MASK_PRESSURE, 0, true).pressure, 1.0); + assert_eq!(pen_sample(512, PEN_MASK_PRESSURE, 0, true, PenPose::default()).pressure, 0.5); + assert_eq!(pen_sample(1024, PEN_MASK_PRESSURE, 0, true, PenPose::default()).pressure, 1.0); assert_eq!( - pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false), + pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false, PenPose::default()), PenSample { pressure: 0.0, eraser: true, pose: PenPose::default() }, "hovering" ); - assert_eq!(pen_sample(0, 0, 0, true).pressure, 1.0, "a pen in contact that reports no pressure presses fully"); - assert_eq!(pen_sample(300, 0, 0, true).pressure, 1.0, "a pressure without the pressure mask is not one"); - assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, 0, true).pressure, 1.0, "as winit, which gives no force for 0"); - assert_eq!(pen_sample(2000, PEN_MASK_PRESSURE, 0, false).pressure, 0.0); + assert_eq!(pen_sample(0, 0, 0, true, PenPose::default()).pressure, 1.0, "a pen in contact that reports no pressure presses fully"); + assert_eq!(pen_sample(300, 0, 0, true, PenPose::default()).pressure, 1.0, "a pressure without the pressure mask is not one"); + assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, 0, true, PenPose::default()).pressure, 1.0, "as winit, which gives no force for 0"); + assert_eq!(pen_sample(2000, PEN_MASK_PRESSURE, 0, false, PenPose::default()).pressure, 0.0); } #[test] diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 3904e6aab..4c9bda1b1 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -7,8 +7,18 @@ //! - One object, the brush manager's order, has the order of the Brushes panel: its parameters //! are the plug-in name and brush name of each brush, each one after a `/` and a space. //! - A Calligraphic brush's parameters are 11 numbers: the first is 1; the diameter, roundness and -//! angle as (low, high) pairs; how the diameter, roundness and angle vary (0 Fixed, 1 Random, 2 -//! Pressure, other codes the pen's other channels); a last number. +//! angle as (low, high) pairs; how the diameter, roundness and angle vary; a last number. The +//! codes follow the order of the Brush Options menu: 0 Fixed, 1 Random, 2 Pressure, 3 Stylus +//! Wheel, 4 Tilt, 5 Bearing, 6 Rotation. A user's 6D Art Pen brushes (#372) vary the diameter +//! with 2, the roundness with 5 and the angle with 6, the angle over a whole turn, as the barrel +//! rotation turns the nib. VectorCraft reads no stylus wheel: 3 is read as Random when the value +//! varies, as an unknown code is. +//! - A path with a Calligraphic brush is a stroke in its art style ([`super::style`]): the filter +//! `(Adobe Calligraphic Brush Tool)`, its stroke's paint and weight (the weight scales the brush: +//! 1 pt draws it at its own size), the brush by name (`/GObjRef : (name) (plug-in) +//! /PluginObject`), and the brush's settings for this path as entries: `Diameter`, `Roundness`, +//! `Angle`, their `…Variation` either side and `…DependsOn` codes, and `AngleRelativeTo` (0 the +//! page). //! - A Scatter, Art or Pattern brush's parameters are fields split by `/`: a number, the art's //! section names (one for Scatter, five for Art and Pattern, empty where there is none, each //! after a space for Art and Pattern), more fields, and last 15 numbers. @@ -168,12 +178,15 @@ fn numbers(s: &str) -> Option> { s.split_whitespace().map(|w| w.parse::().ok().filter(|v| v.is_finite())).collect() } -/// How a value varies, by its code: `None` for a code that isn't Fixed, Random or Pressure. +/// How a value varies, by its code: `None` for the stylus wheel and codes that aren't known. fn variation(code: f64) -> Option { match code { 0.0 => Some(Variation::Fixed), 1.0 => Some(Variation::Random), 2.0 => Some(Variation::Pressure), + 4.0 => Some(Variation::Tilt), + 5.0 => Some(Variation::Bearing), + 6.0 => Some(Variation::Rotation), _ => None, } } @@ -183,6 +196,27 @@ fn mode(code: f64, varies: bool) -> Variation { variation(code).unwrap_or(if varies { Variation::Random } else { Variation::Fixed }) } +/// A Calligraphic brush of angle, roundness and size `values`, each varying `variation` either side +/// as its code in `codes` says. +pub(super) fn calligraphic(values: [f64; 3], variation: [f64; 3], codes: [f64; 3]) -> Calligraphic { + let [angle, roundness, size] = values; + let modes = [0, 1, 2].map(|i| mode(codes[i], variation[i] != 0.0)); + Calligraphic { angle, roundness, size, variation, modes: Some(modes) } +} + +/// Does the brush `draft` draw as `c`, the settings a path's art style gives it? A path whose +/// settings differ (Options of Selected Object) keeps its drawn look: a stroke names a brush, and +/// has no settings of its own. +pub(super) fn draws_as(draft: &BrushDraft, c: &Calligraphic) -> bool { + let DraftKind::Calligraphic(d) = &draft.kind else { return false }; + let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0); + close(d.angle, c.angle) + && close(d.roundness, c.roundness) + && close(d.size, c.size) + && d.variation.iter().zip(&c.variation).all(|(a, b)| close(*a, *b)) + && d.modes() == c.modes() +} + /// The brush named `name` of kind `kind` from its parameters `params`. pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result { let kind = match kind { @@ -191,15 +225,11 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< let [_, d0, d1, r0, r1, a0, a1, dm, rm, am, ..] = n.as_slice() else { return Err(BrushError::Values) }; let mid = |a: f64, b: f64| (a + b) / 2.0; let half = |a: f64, b: f64| (b - a).abs() / 2.0; - let variation = [half(*a0, *a1), half(*r0, *r1), half(*d0, *d1)]; - let modes = [mode(*am, variation[0] != 0.0), mode(*rm, variation[1] != 0.0), mode(*dm, variation[2] != 0.0)]; - DraftKind::Calligraphic(Calligraphic { - angle: mid(*a0, *a1), - roundness: mid(*r0, *r1), - size: mid(*d0, *d1), - variation, - modes: Some(modes), - }) + DraftKind::Calligraphic(calligraphic( + [mid(*a0, *a1), mid(*r0, *r1), mid(*d0, *d1)], + [half(*a0, *a1), half(*r0, *r1), half(*d0, *d1)], + [*am, *rm, *dm], + )) } Tool::Scatter | Tool::Art | Tool::Pattern => { let fields: Vec<&str> = params.split('/').collect(); diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 110c72d8e..0979f047c 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -712,6 +712,7 @@ impl<'a> Reader<'a> { patterns: &self.pattern_tiles, names: &self.global_names, v7_patterns: &self.v7_patterns, + brushes: &self.brush_drafts, space, }, ) @@ -854,6 +855,8 @@ struct StylePaints<'a> { patterns: &'a HashMap, names: &'a ShownNames, v7_patterns: &'a HashSet, + /// The brushes the file defines, which a brushed stroke names. + brushes: &'a [super::super::brush::BrushDraft], space: Affine, } @@ -950,7 +953,17 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: paints.names.get(Global::Pattern, name).to_string(), xf })) } }, - StyleItem::Stroke { .. } => stroke_layer(item)?, + StyleItem::Stroke { brush, .. } => { + // A brushed stroke draws as its look only with the brush the file defines (the + // first of its name, as the Brushes panel keeps it) at the object's settings. + if let Some((name, settings)) = brush { + let draft = paints.brushes.iter().find(|d| &d.name == name)?; + if !super::super::brush::draws_as(draft, settings) { + return None; + } + } + stroke_layer(item)? + } }; if let Some(look) = st.looks.get(n) { with_look(&mut layer, look); @@ -976,6 +989,8 @@ pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) for (n, item) in st.items.iter().enumerate() { let mut layer = match item { StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())), + // A graphic style's brush isn't checked against the file's brushes: it is left out. + StyleItem::Stroke { brush: Some(_), .. } => return None, StyleItem::Stroke { .. } => stroke_layer(item)?, StyleItem::Gradient { .. } | StyleItem::Pattern { .. } => return None, }; @@ -989,9 +1004,10 @@ pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) /// A style's stroke as an appearance item (`None` for a fill). fn stroke_layer(item: &crate::import::ai::style::StyleItem) -> Option { - let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } = item else { return None }; + let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, brush } = item else { return None }; let mut layer = StrokeLayer::new(paint.clone(), line_width(*width)); (layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone()); + layer.brush = brush.as_ref().map(|(name, _)| name.clone()); Some(AppearanceItem::Stroke(layer)) } diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index e1947bfda..8c6cd1f3d 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -32,8 +32,13 @@ //! gradient (`Bb` … `flag (name) x y angle length … Bg` … `BB`); `stroke fill Xd` says whether it //! paints at all. A blend filter's `mode opacity isolate knockout shape Xy` is its transparency. //! +//! A stroke with a Calligraphic brush is its own basic filter, `(Adobe Calligraphic Brush Tool)`, with +//! its stroke's style and the brush's settings for the object as entries (see +//! [`super::brush`]). +//! //! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills -//! with a pattern or gradient too), each with its own transparency and effects, and the effects of +//! with a pattern or gradient too; strokes with a Calligraphic brush too), each with its own +//! transparency and effects, and the effects of //! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and //! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the //! object's chain) are read on a group with no fills or strokes of its own, which keeps its members. @@ -72,6 +77,8 @@ const BAS_RELIEF: &str = "PSAdapter_plugin_BsRl"; const SHAPE_EFFECTS: &str = "Adobe Shape Effects"; // brand-ok: the filter's name in the files const PATHFINDER: &str = "Adobe Pathfinder"; // brand-ok: the filter's name in the files +const CALLIGRAPHIC: &str = "Adobe Calligraphic Brush Tool"; // brand-ok: the filter's and plug-in's name in the files + const CHAIN: &str = "Chain Style Filter"; const STACK: &str = "Stack Style Filter"; const FILL: &str = "Fill Style Filter"; @@ -174,6 +181,8 @@ pub(super) enum StyleItem { miter: f64, dash: Option, profile: Option, + /// A Calligraphic brush: its name, and its settings for this object. + brush: Option<(String, vectorcraft_brush::Calligraphic)>, }, } @@ -231,6 +240,9 @@ struct Filter { gradient_of: Option<(Option, f64)>, /// `(name) … [matrix] p`, and the numbers between the name and the matrix. pattern: Option<(String, Option<[f64; 6]>, Vec)>, + /// The plug-in object it refers to (`/GObjRef : (name) (plug-in) /PluginObject`): its name and + /// plug-in name. + plugin_object: Option<(String, String)>, /// `mode opacity … Xy`. xy: Option<(usize, f32)>, /// `… isolate knockout … Xy`. @@ -259,6 +271,7 @@ impl Default for Filter { gradient: false, gradient_of: None, pattern: None, + plugin_object: None, xy: None, xy_group: None, } @@ -388,6 +401,13 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { } } b"Array" => dict_array = Some(vec![]), + b"PluginObject" if in_basic => { + let mut texts = operands.iter().rev().filter_map(V::text); + if let (Some(plugin), Some(name), Some(f)) = (texts.next(), texts.next(), filters.last_mut()) { + f.plugin_object = Some((name, plugin)); + } + other_entry = Some(None); + } b"Int" | b"Real" | b"Bool" => { // The number right before the type: an earlier one isn't this value. let value = operands.last().and_then(V::num); @@ -1066,13 +1086,46 @@ fn paint_item(f: &Filter) -> Result, ()> { miter: f.miter, dash: f.dash.clone(), profile: width_profile(&f.arrays), + brush: None, })), _ => Err(()), }, + CALLIGRAPHIC => calligraphic_stroke(f).map(Some).ok_or(()), _ => Err(()), } } +/// A Calligraphic brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a +/// colour, names no Calligraphic brush, or its settings aren't all there as numbers. An angle +/// relative to the path isn't read. +fn calligraphic_stroke(f: &Filter) -> Option { + let real = |key: &str| entry(f, key, Kind::Real).ok().flatten(); + let code = |key: &str| entry(f, key, Kind::Int).ok().flatten(); + if !f.paints.0 || f.gradient || f.pattern.is_some() || f.repeated { + return None; + } + if entry(f, "AngleRelativeTo", Kind::Int).ok()?.unwrap_or(0.0) != 0.0 { + return None; + } + let (name, plugin) = f.plugin_object.as_ref()?; + if plugin != CALLIGRAPHIC { + return None; + } + let values = [real("Angle")?, real("Roundness")?, real("Diameter")?]; + let variation = [real("AngleVariation")?, real("RoundnessVariation")?, real("DiameterVariation")?].map(f64::abs); + let codes = [code("AngleDependsOn")?, code("RoundnessDependsOn")?, code("DiameterDependsOn")?]; + Some(StyleItem::Stroke { + paint: f.stroke.clone()?, + width: f.width, + cap: f.cap, + join: f.join, + miter: f.miter, + dash: None, + profile: None, + brush: Some((name.clone(), super::brush::calligraphic(values, variation, codes))), + }) +} + /// Builds an [`ArtStyle`] from a style's filter tree. struct Builder<'a> { filters: &'a [Filter], @@ -1113,7 +1166,7 @@ impl Builder<'_> { let c = &filters[k]; match c.name.as_str() { _ if c.compound => {} - FILL | STROKE => match paint_item(c) { + FILL | STROKE | CALLIGRAPHIC => match paint_item(c) { Ok(Some(StyleItem::Stroke { .. })) => under.strokes = true, Ok(Some(StyleItem::Pattern { .. })) => under.patterns = true, _ => {} @@ -1127,7 +1180,7 @@ impl Builder<'_> { let c = &filters[k]; match c.name.as_str() { _ if c.compound => self.plain = false, - FILL | STROKE => { + FILL | STROKE | CALLIGRAPHIC => { paints += 1; look.visible &= c.visible; match paint_item(c) { @@ -1187,7 +1240,7 @@ fn style(filters: &[Filter]) -> ArtStyle { let f = &filters[id]; match f.name.as_str() { STACK if f.compound => b.stack(id, &mut st), - FILL | STROKE | CHAIN => { + FILL | STROKE | CALLIGRAPHIC | CHAIN => { if let Some((item, look)) = b.item(id) { st.items.push(item); st.looks.push(look); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 79cd13c6b..ba04130b2 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -6481,6 +6481,107 @@ fn a_gradients_br_is_not_a_brush() { assert_eq!(g.gradient.stops.len(), 2); } +/// The Brush Options codes for how a Calligraphic brush's values vary: Tilt, Bearing and Rotation +/// are the pen's other channels (#372: a 6D Art Pen brush varies its diameter with the pressure, +/// its roundness with the bearing and its angle with the barrel's rotation). The stylus wheel isn't +/// read: it varies at random, as an unknown code does. +#[test] +fn a_calligraphic_brushs_pen_channels_come_in_by_their_codes() { + use vectorcraft_brush::Variation::{Bearing, Fixed, Pressure, Random, Rotation, Tilt}; + let modes = |params: &str| { + let d = brush::parse_tool_object(brush::Tool::Calligraphic, "Pen", params).unwrap(); + let brush::DraftKind::Calligraphic(c) = d.kind else { panic!("{d:?}") }; + c.modes.unwrap() + }; + assert_eq!(modes("1 51 69 13 57 -346 14 2 5 6 0"), [Rotation, Bearing, Pressure]); + assert_eq!(modes("1 2 6 40 60 -30 10 4 4 4 0"), [Tilt, Tilt, Tilt]); + assert_eq!(modes("1 2 6 40 60 -30 10 3 3 3 0"), [Random, Random, Random], "the stylus wheel"); + assert_eq!(modes("1 4 4 50 50 10 10 3 9 9 0"), [Fixed, Fixed, Fixed], "nothing to vary"); +} + +/// A 6D Art Pen brush: diameter 4 to 8 with the pressure, roundness 30 to 50 with the bearing and +/// angle -150 to 210 with the barrel's rotation. +const SIX_D: &str = "1 4 8 30 50 -150 210 2 5 6 0"; + +/// The style `Inked`: a stroke in red of weight `weight` with the Calligraphic brush `brush` at +/// diameter, roundness and angle `settings` (each its value and variation either side), varying by +/// the codes `codes`, its angle relative to `relative` (0 the page). +fn brushed_style(brush: &str, weight: f64, settings: [(f64, f64); 3], codes: [u8; 3], relative: u8) -> String { + let [(d, dv), (r, rv), (a, av)] = settings; + let [dm, rm, am] = codes; + format!( + "%AI9_BeginArtStyles\n/KnownStyle :\n(Inked) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n({CALLIGRAPHIC}) 1 0 /Filter ,\n1 /Visible ,\n(Calligraphic Brush) /Title ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\ + /StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 1 0 0 0 Xy\n0 J 0 j {weight} w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\ + {d} /Real (Diameter) ,\n{dv} /Real (DiameterVariation) ,\n{dm} /Int (DiameterDependsOn) ,\n\ + {r} /Real (Roundness) ,\n{rv} /Real (RoundnessVariation) ,\n{rm} /Int (RoundnessDependsOn) ,\n\ + {a} /Real (Angle) ,\n{av} /Real (AngleVariation) ,\n{am} /Int (AngleDependsOn) ,\n{relative} /Int (AngleRelativeTo) ,\n\ + ({brush}) /UnicodeString (DisplayString) ,\n/GObjRef : ({brush}) ({CALLIGRAPHIC}) /PluginObject ; (Brush) ,\n; /Dict ;\n /Part ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n\ + /BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\ + /FillStyle : 0 O\n0 0 0 0 k\n0 0 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\ + ;\n /Part ,\n;\n%AI9_EndArtStyles\n" + ) +} + +/// The settings [`SIX_D`] has, as an art style writes them. +const SIX_D_SETTINGS: [(f64, f64); 3] = [(6.0, 2.0), (40.0, 10.0), (30.0, 180.0)]; + +/// A brushed path as the file writes it: its drawn look (a filled outline) in a group, then the +/// path itself on `%_` lines, unpainted, with the style `Inked`. +fn brushed_path() -> String { + let line = "20 20 m\n100 60 L\n"; + format!("0 Ae\nu\n{RED}{}f\nU\n0 0 Xd\n6 () XW\n%_{}n\n1 (Inked) XW\n", rect_path(20.0, 18.0, 100.0, 62.0), line.replace('\n', "\n%_")) +} + +/// A path drawn with a Calligraphic brush comes in as the path, its stroke in that brush at its +/// weight (the weight scales the brush), not as its drawn look: here a 6D Art Pen brush, whose +/// channels the brush keeps (#372). +#[test] +fn a_path_with_a_calligraphic_brush_comes_in_brushed() { + let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D); + let style = brushed_style("Six D", 0.5, SIX_D_SETTINGS, [2, 5, 6], 0); + let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path()))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path, not its look: {a:#?}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + let st = a[0].appearance.stroke().unwrap(); + assert_eq!((st.brush.as_deref(), st.width, st.paint.color()), (Some("Six D"), 0.5, Some(Color::rgb(1.0, 0.0, 0.0)))); + assert!(a[0].appearance.fill().is_none_or(|f| f.paint.is_none()), "{:?}", a[0].appearance); + use vectorcraft_brush::{BrushKind, Variation}; + let lib = vectorcraft_brush::library(&r.doc); + let BrushKind::Calligraphic(c) = brush_named(&lib, "Six D") else { panic!("calligraphic") }; + assert_eq!(c.modes, Some([Variation::Rotation, Variation::Bearing, Variation::Pressure])); + // With no pen recorded on the path, the nib is the brush's own at the stroke's weight. + let pieces = vectorcraft_brush::stroke_pieces(&r.doc, &lib[0], &a[0].path_data().unwrap().to_bezpath(), st); + let height = pieces.iter().filter_map(|n| n.geometric_bounds()).map(|b| b.height()).fold(0.0, f64::max); + assert!(height > 40.0 && height < 46.0, "the line's rise and about half the nib's 3 pt: {height}"); +} + +/// A brushed path keeps its drawn look when its stroke can't be read as the brush: the object's +/// own settings differ from the brush's (Options of Selected Object), its angle follows the path, +/// or the file defines no brush of its name. +#[test] +fn a_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { + let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D); + let mut own = SIX_D_SETTINGS; + own[0] = (9.0, 2.0); + let cases = [ + ("its own settings", def.clone(), brushed_style("Six D", 1.0, own, [2, 5, 6], 0)), + ("its own modes", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 4], 0)), + ("an angle on the path", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 1)), + ("no such brush", def, brushed_style("Other", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0)), + ]; + for (why, def, style) in cases { + let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path()))); + let a = art(&r.doc); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{why}: the drawn look, {a:#?}"); + assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings); + } +} + use read::{GRAPH_PLAIN, GRAPH_UNENDED}; /// Editing data in the legacy format (version 7 specification, chapter 2): `%AI5_FileFormat 2.0`, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2108e78dd..e989e2c94 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-05 · **Change:** minor (PDF import: DeviceN gradients of a spot ink over process plates, #3235) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (`.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 @@ -150,7 +150,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files 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 (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke or Convert to Shape comes in as itself with them and their saved settings; one with more, other effects, an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a brush or dashes aligned to corners comes in as its drawn look (a group named after it). The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. 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 is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, 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 its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files 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 (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke or Convert to Shape comes in as itself with them and their saved settings; one with more, other effects, an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. 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 is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, 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 its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format @@ -198,6 +198,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | `.ai` import (#372): a path with a Calligraphic brush comes in as the path with the brush on its stroke (its weight scales the brush) when its art style's brush settings are the brush's own and its angle is on the page, else as its drawn look; a Calligraphic brush's Tilt, Bearing and Rotation codes come in as those channels instead of Random. Checked against a user's file of 6D Art Pen strokes (used locally, not committed); synthetic tests in `import::ai::tests` | | 2026-11-05 | minor | PDF import: the stops of an axial or radial shading in a DeviceN space of one spot ink and process plates link to the ink's swatch and keep the plates (`GradientStop.plates` in the native format); PDF and EPS export write them in a DeviceN space again (#3235) | | 2026-11-04 | minor | Raster/PSD export uses document resolution unless ppi or scale is supplied; CLI run/convert accept --ppi. Synthetic engine, CLI and MCP regressions cover dimensions and override precedence; scores and hours unchanged. | | 2026-11-01 | minor | Native format: a symbol's Symbol Type (`dynamic`) and a dynamic symbol instance's appearance changes to objects of its art (`symbol_overrides`) round-trip; both are left out when unset, so other documents save unchanged (#4938). `.ai` reading and writing of dynamic symbols remains. | diff --git a/docs/gaps.md b/docs/gaps.md index 79774d1aa..172234f16 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (`.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -36,6 +36,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | G9, G14: `.ai` paths with a Calligraphic brush open brushed (the brush on the stroke, at its weight) where the object's settings are the brush's own, and the brush's Tilt, Bearing and Rotation codes come in as those channels; Windows reads the pen's tilt and barrel rotation from Windows Ink (#372). Checked against a user's file of 6D Art Pen strokes, used locally; not tried with a pen. Scores and estimates unchanged. | | 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `addon.contour.set` is the command behind it. It is a built-in add-on: Object › Plug-ins › Contour, and its own heading in All Tools, with no default shortcut or toolbar slot. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | | 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | | 2026-11-05 | minor | G12: Hyphenation › Default Language sets the language of new type unless Document Setup chose one, stored as a tag; each run hyphenates in its own language, English only so far, so 2 Preferences options stay unused (#394) | @@ -182,8 +183,14 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done (2026-10-16):** EPS legacy mesh corners and vector opacity masks retained; winding-declared compound member groups keep shared paint. Raster print-page minification no longer causes a false editing-data fallback; comparison limits remain unchanged. Synthetic regressions cover damage and missing artwork. This narrows G9; scores and estimates unchanged. +- **Done (2026-11-05):** paths with a Calligraphic brush open as paths with the brush on their stroke, at their + weight, where the object's brush settings are the brush's own and its angle is on the page (#372, from a + user's file of 6D Art Pen strokes: all 455 of its brushed styles), and a Calligraphic brush's Tilt, Bearing and + Rotation (codes 4, 5 and 6) come in as those channels instead of Random. Others keep their drawn look. + - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead - of 14), open speed on large files (#758), live effects, brushes and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope + of 14), open speed on large files (#758), live effects, Scatter, Art, Pattern and Bristle brushes on paths, a + path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope (undocumented): a PDF-compatible `.ai` opens in Illustrator 2018 without its layers and groups (#868). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification instead, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and @@ -548,9 +555,11 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall The pen's eraser end on Windows with Windows Ink, read from the pen's pointer messages; not yet tried with a pen. On Linux under X11 and XWayland and on macOS, the Paintbrush records a pen's tilt, bearing and barrel rotation, and Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac with a tablet). + On Windows with Windows Ink, the pen's tilt and barrel rotation where the pen reports them (#372; not yet + tried with a pen). - **Missing:** pen pressure on native Wayland (#491, #764) — winit 0.30 reports none there; tilt, bearing and - barrel rotation on Windows, native Wayland and the web, and for Art, Pattern and Bristle brushes (6D Art Pen, - #372); the eraser end on native Wayland. + barrel rotation on native Wayland and the web, and for Art, Pattern and Bristle brushes (6D Art Pen, #372); + the eraser end on native Wayland. - **Estimate:** 4–10 h, partly waiting on winit 0.31; needs pen hardware to verify. ## G5 Brushes, symbols and libraries diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 0da3e279e..5f6c87145 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** patch (Linux: on a Wayland session with a pen attached, Automatic opens the window through XWayland, where the pen draws, #4967) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Windows: the pen's tilt and barrel rotation from Windows Ink, #372) · **Target:** Adobe Illustrator 2026 (30.x) What hardware Illustrator uses, per platform, against VectorCraft. Illustrator's side is from its public documentation (GPU Performance, touch workspace, pen pressure pages); ours from the code @@ -22,7 +22,7 @@ It reads the pen's tilt and barrel rotation on macOS and on Linux under X11 and | Hybrid-graphics GPU choice | automatic | power-saving GPU by default, Preferences › Graphics Processor | same; DX12 then OpenGL; no Vulkan unless asked (#545, #806) | the desktop's GPU | browser | done | | Restart on another GPU when the first fails to present | — | yes (#502, #964) | yes | yes | restart on lost device (#369) | done | | Pen pressure | yes (Wacom, Windows Ink, macOS tablet events) | yes, read from AppKit's tablet events before the window gets them (winit reports none), each move of a stroke with its own pressure, mouse event coalescing off (#852; not yet tried on a Mac) | yes (pointer force via `WM_POINTER`) | under X11 and XWayland (Preferences › Performance › Linux Display Server, #1151, #2028; Automatic uses XWayland when a pen is attached on a Wayland session, #4967), read from XInput2 raw events (#764); **no** on native Wayland, where winit reports none and the pen may not reach the window (#491) | browser pointer events, with coalesced events; Apple Pencil on iPad (hover, palm rejection: fingers navigate after a pen contact until reload) | partial: 2–4 h for Wayland (winit 0.31), plus a check on a Mac | -| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | read from AppKit's tablet events (tilt, and the barrel rotation of a pen that reports one); Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac) | no | under X11 and XWayland, read from XInput2 raw events; Calligraphic and Scatter brushes vary with them (#764); **no** on native Wayland | no | partial: Windows, native Wayland and the web read none, and Art, Pattern and Bristle brushes do not use them; 4–8 h after the input layer (#372) | +| Pen tilt, bearing, barrel rotation (6D Art Pen) | yes, drives brushes | read from AppKit's tablet events (tilt, and the barrel rotation of a pen that reports one); Calligraphic and Scatter brushes vary with them (#764; not yet tried on a Mac) | with Windows Ink, read from the pen's `POINTER_PEN_INFO` (tilt and barrel rotation where its pen mask says the pen reports them); Calligraphic and Scatter brushes vary with them (#372; not yet tried with a pen) | under X11 and XWayland, read from XInput2 raw events; Calligraphic and Scatter brushes vary with them (#764); **no** on native Wayland | no | partial: native Wayland and the web read none, and Art, Pattern and Bristle brushes do not use them; 3–6 h (#372) | | Pen eraser end | yes | the eraser end (AppKit's tablet proximity events) selects the Eraser tool and the tip brings back the tool used before, as on Linux (#852; not yet tried on a Mac) | with Windows Ink, an inverted pen or its eraser button (`PEN_FLAG_INVERTED`, `PEN_FLAG_ERASER` in the pen's pointer messages) selects the Eraser tool and the tip brings back the tool used before, as on Linux (not yet tried with a pen) | under X11 and XWayland, the eraser end selects the Eraser tool and the tip brings back the tool used before (#764); **no** on native Wayland | a press of the eraser end (Pointer Events eraser button, `button` 5) selects the Eraser tool, and that press erases; a press of the tip brings back the tool used before; Pointer Events report no end while the pen hovers | partial | | Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch, scroll and rotate | pinch, pan (a second finger cancels the first finger's tool press, #4198); two-finger tap undo, three-finger redo (#585) | same where winit reports touch | eframe until the first pen contact, then Pointer Events (pen and fingers told apart); long press opens the context menu | partial: touch workspace and touch-screen rotation missing, 3–5 h | | Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done | @@ -35,6 +35,7 @@ It reads the pen's tilt and barrel rotation on macOS and on Linux under X11 and | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Windows (#372): with Windows Ink, each pen pointer message's `POINTER_PEN_INFO` gives the pen's tilt (`tiltX`, `tiltY`) and barrel rotation where its pen mask says the pen reports them, written into the canvas's pen feed as on macOS and Linux, so a 6D Art Pen's barrel turns a Calligraphic brush's nib. Unit-tested (`windows_stylus::tests::a_pens_tilt_and_rotation_come_where_it_reports_them`); not yet tried with a pen, so the score is unchanged (estimated) | | 2026-11-01 | patch | Linux (#4967): on a Wayland session, Linux Display Server › Automatic opens the window through XWayland when a pen is attached as the app starts, as X11 (XWayland) When a Pen Is Attached does and as PhotoCraft does; on native Wayland winit 0.30 gets no tablet input and KWin 6.3+ and Mutter emulate no pointer, so the pen moved only the compositor's cursor. `VECTORCRAFT_NATIVE_WAYLAND=1` keeps Wayland. Unit-tested (`xwayland::tests`); not tried with a tablet; score unchanged (estimated) | | 2026-10-30 | patch | Touch (#4198): a second finger landing cancels the tool press the first finger began, also in the same frame (the Pencil's stroke and a Selection move are rolled back, nothing enters the undo history), and a finger landing while another is down presses nothing, so a pinch or two-finger pan only navigates. Headless canvas test `a_second_finger_cancels_the_tool_gesture_the_first_began`; not tried on a touch screen; score unchanged (estimated) | | 2026-10-24 | minor | macOS: an AppKit local event monitor reads each mouse and tablet event's pen pressure and the pen's end before the window gets it and writes them into the canvas's pen feed (#852): each pointer move of a stroke keeps its own pressure, mouse event coalescing is off, and the eraser end selects the Eraser tool. The mapping is unit-tested on every platform and the macOS code type-checks for `aarch64-apple-darwin`; not yet tried on a Mac with a tablet, so the score is unchanged (estimated) |