From 719e3450b21ba228e4142c566b8240c79810a9d1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 17:45:34 -0800 Subject: [PATCH 1/3] .ai import and save: Art brushes keep None, their width, scale and flips A .ai path with an Art brush opened with the brush on its stroke only when the brush stretched its art at 100% in Tints, unflipped. Paths with an Art brush in None, at another fixed width, with Stretch Between Guides, or flipped along or across now open as the path with the brush on its stroke too, and the brush in the Brushes panel has those options. The guides come in as fractions of the art, from the lengths of the art outside them. A brush with Scale Proportionately comes into the panel with that option, and its paths keep their drawn look. Paths keep their drawn look when their settings that change the drawing differ from the brush's, and when the brush is in Tints and Shades or Hue Shift, has a width that follows the pen, has guides that leave no art between them, or has a Spacing, Adjustment or FoldAdjustment other than 0, 1 and 1. The .ai save writes the same options, so such brushes and the paths that use them come back as they were; a path with a proportional brush is written as its brush's art. --- crates/eps/src/ai_editing/tests.rs | 76 ++++++++-- crates/eps/src/ai_editing/writer/brushes.rs | 100 +++++++++---- crates/eps/src/import/ai/brush.rs | 157 ++++++++++++++++---- crates/eps/src/import/ai/read.rs | 4 +- crates/eps/src/import/ai/read/directives.rs | 2 +- crates/eps/src/import/ai/read/ops.rs | 10 +- crates/eps/src/import/ai/style.rs | 54 +++---- crates/eps/src/import/ai/tests.rs | 150 ++++++++++++++++--- docs/file-format-parity.md | 15 +- docs/gaps.md | 10 +- docs/target-app-parity.md | 2 +- 11 files changed, 436 insertions(+), 144 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 3af515181c..9ffe019950 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -8174,7 +8174,7 @@ impl ArtBrushOptions { /// A path with the Calligraphic brush, and one with an Art brush in Tints whose art bends along the /// path, come back as those paths with the brush on their stroke at its weight, not as their drawn -/// look; a path with the Art brush that keeps its art's colors comes back as its drawn look. +/// look; so does a path with the Art brush that keeps its art's colors. #[test] fn brushed_paths_are_read_back_brushed() { let mut doc = brushes_document(); @@ -8194,8 +8194,63 @@ fn brushed_paths_are_read_back_brushed() { assert_eq!(art[1].path_data(), ours[1].path_data()); brushed(&mut doc, "Plain ribbon", (20.0, 180.0), (280.0, 180.0), 1.0); let out = export_ai_editing(&doc, &opts()).unwrap(); - assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); - assert!(matches!(read_back(&doc).layers[0].children().unwrap()[2].kind, NodeKind::Group { .. })); + assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[2]; + assert!(matches!(n.kind, NodeKind::Path { .. }), "{n:#?}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Plain ribbon")); +} + +/// Art brushes at another width, flipped along or across, scaled proportionately or stretched +/// between guides (flipped along too, or at guides the written numbers round) are written with those +/// options, and a path with each comes back as the path with the brush on its stroke, except Scale +/// Proportionately's, which is written as its brush's art. +#[test] +fn art_brushes_with_options_are_read_back_with_them() { + use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind}; + let thirds = ArtScale::BetweenGuides { start: 1.0 / 3.0, end: 2.0 / 3.0 }; + let options: Vec<(&str, ArtBrush)> = vec![ + ("Wide", ArtBrush { width: 250.0, ..ArtBrush::default() }), + ("Along", ArtBrush { flip_along: true, ..ArtBrush::default() }), + ("Across", ArtBrush { flip_across: true, ..ArtBrush::default() }), + ("Proportional", ArtBrush { scale: ArtScale::Proportional, width: 50.0, ..ArtBrush::default() }), + ("Guides", ArtBrush { scale: ArtScale::BetweenGuides { start: 0.25, end: 0.5 }, ..ArtBrush::default() }), + ("Guides along", ArtBrush { scale: ArtScale::BetweenGuides { start: 0.25, end: 0.5 }, flip_along: true, ..ArtBrush::default() }), + ("Thirds", ArtBrush { scale: thirds, flip_along: true, ..ArtBrush::default() }), + ]; + let lib: Vec = options + .iter() + .map(|(name, a)| Brush { name: (*name).into(), kind: BrushKind::Art(ArtBrush { art: brush_art_node(), ..a.clone() }) }) + .collect(); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + for (i, b) in lib.iter().enumerate() { + let y = 20.0 + 25.0 * i as f64; + brushed(&mut doc, &b.name, (20.0, y), (280.0, y), 1.0); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = read_back(&doc); + let got = vectorcraft_brush::library(&back); + assert_eq!(got.len(), lib.len()); + for (g, w) in got.iter().zip(&lib) { + let (BrushKind::Art(g), BrushKind::Art(w)) = (&g.kind, &w.kind) else { panic!("art") }; + if w.scale == thirds { + let ArtScale::BetweenGuides { start, end } = g.scale else { panic!("guides: {g:?}") }; + assert!((start - 1.0 / 3.0).abs() < 1e-6 && (end - 2.0 / 3.0).abs() < 1e-6, "{g:?}"); + assert_eq!(ArtBrushOptions::of(&ArtBrush { scale: thirds, ..g.clone() }), ArtBrushOptions::of(w)); + } else { + assert_eq!(ArtBrushOptions::of(g), ArtBrushOptions::of(w)); + } + } + for (n, b) in back.layers[0].children().unwrap().iter().zip(&lib) { + if b.name == "Proportional" { + assert!(matches!(n.kind, NodeKind::Group { .. }), "{}: the brush's art: {n:#?}", b.name); + continue; + } + assert!(matches!(n.kind, NodeKind::Path { .. }), "{}: {n:#?}", b.name); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some(b.name.as_str())); + } } /// A compound path with the Calligraphic brush comes back as that compound path with the brush on its @@ -8499,8 +8554,8 @@ fn brushes_write_the_same_bytes_again() { /// beside a brush that comes back, and each changed from a brush that comes back by the one setting /// that leaves it out: an Art brush whose art is a group with a fill of its own, a Scatter brush whose /// art is a path without points, an Art brush whose art is a group without members, Pattern and Bristle -/// brushes, Art brushes flipped along or across, overlapping, at another width, with a width that -/// varies, in another direction or scale, Art and Scatter brushes in Tints and Shades or Hue Shift, a +/// brushes, Art brushes overlapping, with a width that varies, in another direction or with guides +/// that leave no art between them, Art and Scatter brushes in Tints and Shades or Hue Shift, a /// brush whose name holds a slash and a second brush of a name. A brush left out doesn't take its /// name from a later one. #[test] @@ -8524,13 +8579,10 @@ fn brushes_the_reader_cant_read_are_left_out() { ("empty group", art_with(&|a| a.art = Node::group(vectorcraft_doc::NodeId(1), vec![]))), ("pattern", BrushKind::Pattern(PatternBrush::default())), ("bristle", BrushKind::Bristle(Bristle::default())), - ("flipped along", art_with(&|a| a.flip_along = true)), - ("flipped across", art_with(&|a| a.flip_across = true)), ("overlap", art_with(&|a| a.overlap = true)), - ("width", art_with(&|a| a.width = 50.0)), ("width varies", art_with(&|a| a.width_mode = Some(Variation::Pressure))), ("direction", art_with(&|a| a.direction = Direction::RightToLeft)), - ("proportional", art_with(&|a| a.scale = ArtScale::Proportional)), + ("guides with no art between them", art_with(&|a| a.scale = ArtScale::BetweenGuides { start: 0.5, end: 0.5 })), ("art tints and shades", art_with(&|a| a.colorization = Colorization::TintsAndShades)), ("art hue shift", art_with(&|a| a.colorization = hue)), ( @@ -8740,8 +8792,8 @@ fn an_empty_brushes_panel_comes_back_empty() { } /// A path with one of a new document's brushes writes the brushes the reader reads, and keeps its -/// brush. The new document's Art brush with other options, its Pattern brushes and its Bristle -/// brushes are left out with a warning, so the file opens without them. +/// brush. The new document's Pattern brushes and its Bristle brushes are left out with a warning, +/// so the file opens without them. #[test] fn a_new_documents_brush_on_a_path_writes_the_brushes() { let mut doc = Document::new(300.0, 200.0); @@ -8751,7 +8803,7 @@ fn a_new_documents_brush_on_a_path_writes_the_brushes() { let back = read_back(&doc); let n = &back.layers[0].children().unwrap()[0]; assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("3 pt. Round")); - let left_out = ["Arrow", "Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"]; + let left_out = ["Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"]; let want: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).filter(|n| !left_out.contains(n)).collect(); let lib = vectorcraft_brush::library(&back); assert_eq!(lib.iter().map(|b| b.name.as_str()).collect::>(), want); diff --git a/crates/eps/src/ai_editing/writer/brushes.rs b/crates/eps/src/ai_editing/writer/brushes.rs index 2c281e702d..e8a7d10b15 100644 --- a/crates/eps/src/ai_editing/writer/brushes.rs +++ b/crates/eps/src/ai_editing/writer/brushes.rs @@ -23,7 +23,7 @@ use super::{Writer, Xy}; use crate::ai_ops; use crate::ps; -pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't stretch their art along the path from left to right at 100% unflipped without overlap, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps"; +pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't run their art from left to right, that overlap or whose width follows the pen, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps"; /// Most brushes written (the reader's limit). const MAX_BRUSHES: usize = 4096; @@ -145,22 +145,71 @@ fn scatter_params(s: &Scatter, art: &str) -> Option { Some(format!("2 /{art}/ 5 0 0 0 / {}", nums6(&numbers))) } -/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read -/// (it reads an Art brush that stretches its art along the path from left to right at 100%, -/// unflipped, without overlap, in Tints or keeping its colors). -fn art_params(a: &ArtBrush, art: &str) -> Option { - let plain = a.direction == Direction::LeftToRight - && matches!(a.scale, ArtScale::Stretch) - && a.width == 100.0 - && a.width_mode.is_none() - && !a.flip_along - && !a.flip_across - && !a.overlap; - if !plain { +/// An Art brush's settings as its parameters' numbers 2 to 15 give them and a brushed stroke's filter +/// holds them ([`ART_SETTINGS`]): `None` for options the reader doesn't read (it reads an Art brush +/// that runs its art from left to right without overlap, at a fixed width, in Tints or keeping its +/// colors). The guides of Stretch Between Guides are the lengths of the art before the first and +/// after the second, measured before the art is flipped along the path. +fn art_settings(a: &ArtBrush) -> Option<[f64; 14]> { + if a.direction != Direction::LeftToRight || a.width_mode.is_some() || a.overlap { return None; } - let c = colorization_code(a.colorization)?; - let numbers = [1.0, c, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]; + let length = a.art.geometric_bounds().map_or(0.0, |b| b.width()); + let (proportional, segmented, before, after) = match a.scale { + ArtScale::Stretch => (0.0, 0.0, 0.0, 0.0), + ArtScale::Proportional => (1.0, 0.0, 0.0, 0.0), + ArtScale::BetweenGuides { start, end } if start < end => { + let (before, after) = (start * length, (1.0 - end) * length); + let (before, after) = if a.flip_along { (after, before) } else { (before, after) }; + (0.0, 1.0, before, after) + } + ArtScale::BetweenGuides { .. } => return None, + }; + let scaling = a.width / 100.0; + let flip = |on: bool| f64::from(u8::from(on)); + Some([ + colorization_code(a.colorization)?, + scaling, + 0.0, + 1.0, + flip(a.flip_along), + flip(a.flip_across), + proportional, + scaling, + scaling, + 0.0, + segmented, + before, + after, + 1.0, + ]) +} + +/// The entries of a brushed stroke's filter that hold an Art brush's settings, in the order of +/// [`art_settings`], with how each is written. +const ART_SETTINGS: [(&str, &str); 14] = [ + ("Colorization", "Int"), + ("Scaling", "Real"), + ("Spacing", "Real"), + ("Adjustment", "Int"), + ("Counterclockwise", "Int"), + ("UpsideDown", "Int"), + ("Proportional", "Bool"), + ("MinScaling", "Real"), + ("MaxScaling", "Real"), + ("ScaleYDependsOn", "Int"), + ("Segmented", "Bool"), + ("StartSegmentLength", "Real"), + ("EndSegmentLength", "Real"), + ("FoldAdjustment", "Bool"), +]; + +/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read +/// ([`art_settings`]). +fn art_params(a: &ArtBrush, art: &str) -> Option { + let settings = art_settings(a)?; + let mut numbers = vec![1.0]; + numbers.extend(settings); Some(format!("2 / {art}/ / / / / 0 0/ 5 0 0 0 / {}", nums6(&numbers))) } @@ -458,25 +507,10 @@ impl Writer<'_> { d.push_str("0 /Int (AngleRelativeTo) ,\n"); "(Calligraphic Brush) /Title ,\n" } - BrushKind::Art(_) => { + BrushKind::Art(a) => { d.push_str("15 /Int (Adobe Effect Expand Before Version) ,\n"); // brand-ok: the entry's name in the files - for (key, value, kind) in [ - ("StartSegmentLength", "0", "Real"), - ("EndSegmentLength", "0", "Real"), - ("Counterclockwise", "0", "Int"), - ("MinScaling", "1", "Real"), - ("MaxScaling", "1", "Real"), - ("Colorization", "1", "Int"), - ("Spacing", "0", "Real"), - ("Adjustment", "1", "Int"), - ("Proportional", "0", "Bool"), - ("Segmented", "0", "Bool"), - ("UpsideDown", "0", "Int"), - ("FoldAdjustment", "1", "Bool"), - ("ScaleYDependsOn", "0", "Int"), - ("Scaling", "1", "Real"), - ] { - let _ = writeln!(d, "{value} /{kind} ({key}) ,"); + for ((key, kind), value) in ART_SETTINGS.iter().zip(art_settings(a)?) { + let _ = writeln!(d, "{} /{kind} ({key}) ,", nums6(&[value])); } "(Art Brush) /Title ,\n" } diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 5a5db47114..b995b1bda0 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -36,7 +36,13 @@ //! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the //! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the //! rotation follows the path (14), and 1 Tints (1). -//! - Art: 1 Tints (2). +//! - Art: numbers 2 to 15 are the settings a path's art style holds as entries ([`ArtSettings`]): +//! the colorization (2: 0 None, 1 Tints; other codes color the art otherwise), the width as a factor +//! (3), `Spacing` (4), `Adjustment` (5), flipped along (6) and across (7), Scale Proportionately +//! (8), the lowest and highest width when the width follows the pen (9, 10, not drawn otherwise), +//! how the width varies (11, 0 Fixed), Stretch Between Guides (12) and the lengths of the art +//! before its first guide and after its second (13, 14, in the art's units, measured before +//! the art is flipped along the path), and `FoldAdjustment` (15). //! - Pattern: its scale as a factor (3). //! - A Bristle brush's parameters start with 8 numbers: two codes, then the stiffness, bristle //! thickness, density, paint opacity and bristle length as fractions and the size in @@ -45,14 +51,15 @@ //! [`super::read`]), usually in a group after its frame, an unpainted rectangle around it. A frame //! at the art's bounds isn't kept ([`without_frame`]); a wider one is. //! -//! What isn't read keeps its default: the Art brush's other options, the pattern brush's tiles +//! What isn't read keeps its default: an Art brush's settings that VectorCraft doesn't draw as the +//! file does ([`art_options`]) apart from its colorization, the pattern brush's tiles //! (which slot is which tile isn't known) and the colorization of the other brushes, which is left //! at None so the art keeps its own colors. use std::collections::{HashMap, HashSet}; use kurbo::Affine; -use vectorcraft_brush::{ArtBrush, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; +use vectorcraft_brush::{ArtBrush, ArtScale, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; use vectorcraft_color::Paint; use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; @@ -118,13 +125,13 @@ pub(super) enum DraftKind { brush: Scatter, art: String, }, - /// The art is the section named `art`. `plain`: the brush stretches its art along the path, - /// unflipped, at 100% in Tints (see [`art_is_plain`]), so [`Self::Art`]'s `brush` draws as the - /// file's brush does. + /// The art is the section named `art`. `settings`: the brush's settings when [`art_options`] + /// reads them, so [`Self::Art`]'s `brush` draws as the file's brush does (its guides set from + /// its art's length by [`finish_brushes`]). Art { brush: ArtBrush, art: String, - plain: bool, + settings: Option, }, /// The five art sections the brush names, in its order (which is which tile isn't known), and /// its scale (%). @@ -226,12 +233,17 @@ pub(super) fn calligraphic(values: [f64; 3], variation: [f64; 3], codes: [f64; 3 Calligraphic { angle, roundness, size, variation, modes: Some(modes) } } +/// An Art brush's settings: its object's numbers 2 to 15, which a path's art style holds as the +/// entries `Colorization`, `Scaling`, `Spacing`, `Adjustment`, `Counterclockwise`, `UpsideDown`, +/// `Proportional`, `MinScaling`, `MaxScaling`, `ScaleYDependsOn`, `Segmented`, +/// `StartSegmentLength`, `EndSegmentLength` and `FoldAdjustment`, in that order. +pub(super) type ArtSettings = [f64; 14]; + /// The settings a path's art style gives its stroke's brush. #[derive(Clone, Debug, PartialEq)] pub(super) enum PathBrush { Calligraphic(Calligraphic), - /// An Art brush whose art stretches along the path, at the brush's own width, in Tints. - Art, + Art(ArtSettings), } /// Does an Art brush draw `art` as the file does? It bends paths in solid colors: other objects @@ -250,8 +262,9 @@ pub(super) fn art_bends(art: &Node) -> bool { bends } -/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does. -pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap) -> HashSet { +/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does, with the +/// length of each (its width, the art running left to right). +pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap) -> HashMap { let names: HashSet<&String> = drafts .iter() .filter_map(|d| match &d.kind { @@ -259,13 +272,14 @@ pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap) -> _ => None, }) .collect(); - names.into_iter().filter(|n| art.get(*n).is_some_and(art_bends)).cloned().collect() + names.into_iter().filter_map(|n| art.get(n).filter(|a| art_bends(a)).and_then(|a| a.geometric_bounds()).map(|b| (n.clone(), b.width()))).collect() } /// Does the brush `draft` draw as `settings`, 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. `bending`: [`bending_art`]. -pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashSet) -> bool { +/// has no settings of its own. An Art brush's settings that don't draw ([`drawn`]) may differ. +/// `bending`: [`bending_art`]. +pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashMap) -> bool { let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0); match (&draft.kind, settings) { (DraftKind::Calligraphic(d), PathBrush::Calligraphic(c)) => { @@ -275,16 +289,81 @@ pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashS && d.variation.iter().zip(&c.variation).all(|(a, b)| close(*a, *b)) && d.modes() == c.modes() } - (DraftKind::Art { plain: true, art: name, .. }, PathBrush::Art) => bending.contains(name), + (DraftKind::Art { settings: Some(d), art: name, .. }, PathBrush::Art(s)) => { + drawn(d).iter().zip(drawn(s)).all(|(a, b)| close(*a, b)) && bending.get(name).is_some_and(|length| opens_live(d, *length)) + } _ => false, } } -/// Are an Art brush's last 15 numbers those of a brush that stretches its art along the path, -/// unflipped, at 100% in Tints? The numbers aren't read otherwise: any other value keeps its paths' -/// drawn look. -fn art_is_plain(numbers: &[f64; 15]) -> bool { - *numbers == [1.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0] +/// Art brush settings `s` with those that don't draw set to 0: the lowest and highest width while the +/// width is fixed, and the guides' lengths without Stretch Between Guides. +fn drawn(s: &ArtSettings) -> ArtSettings { + let mut out = *s; + if s[9] == 0.0 { + (out[7], out[8]) = (0.0, 0.0); + } + if s[10] == 0.0 { + (out[11], out[12]) = (0.0, 0.0); + } + out +} + +/// What an Art brush's settings give VectorCraft's Art brush, its guides apart ([`guides`]): `None` +/// when VectorCraft doesn't draw them as the file does: a colorization other than None or Tints, a +/// width outside [`ArtBrush::WIDTH_RANGE`] or one that varies, both Scale Proportionately and Stretch +/// Between Guides, a flip or scale that isn't 0 or 1, or a `Spacing`, `Adjustment` or +/// `FoldAdjustment` other than 0, 1 and 1 (what other values change isn't known). +fn art_options(s: &ArtSettings) -> Option { + let [colorization, scaling, spacing, adjustment, along, across, proportional, _, _, varies, segmented, _, _, fold] = *s; + let switch = |v: f64| match v { + 0.0 => Some(false), + 1.0 => Some(true), + _ => None, + }; + let colorization = match colorization { + 0.0 => Colorization::None, + 1.0 => Colorization::Tints, + _ => return None, + }; + let width = scaling * 100.0; + let (low, high) = ArtBrush::WIDTH_RANGE; + if !(low..=high).contains(&width) || varies != 0.0 || spacing != 0.0 || adjustment != 1.0 || fold != 1.0 { + return None; + } + let scale = match (switch(proportional)?, switch(segmented)?) { + (false, false) => ArtScale::Stretch, + (true, false) => ArtScale::Proportional, + (false, true) => ArtScale::BetweenGuides { start: 0.0, end: 1.0 }, + (true, true) => return None, + }; + Some(ArtBrush { scale, width, flip_along: switch(along)?, flip_across: switch(across)?, colorization, ..ArtBrush::default() }) +} + +/// The guides of Art brush settings `s` (with Stretch Between Guides) for art of length `length`, as +/// [`ArtScale::BetweenGuides`] fractions of the art as it runs along the path: `None` when they leave +/// no art between them. The settings measure the art before it is flipped along the path. +fn guides(s: &ArtSettings, length: f64) -> Option<(f64, f64)> { + let (before, after) = (s[11], s[12]); + if !(before >= 0.0 && after >= 0.0 && before + after < length) { + return None; + } + let (before, after) = if s[4] == 1.0 { (after, before) } else { (before, after) }; + Some((before / length, 1.0 - after / length)) +} + +/// Do Art brush settings `s` draw art of length `length`: no Stretch Between Guides, or guides with +/// art between them? +fn guides_fit(s: &ArtSettings, length: f64) -> bool { + s[10] == 0.0 || guides(s, length).is_some() +} + +/// Does a path with Art brush settings `s`, its art of length `length`, open with the brush on its +/// stroke? Its guides must fit ([`guides_fit`]). Scale Proportionately keeps the drawn look: +/// VectorCraft's Art brush sizes proportional art by the path's length and not by the stroke's +/// weight. +fn opens_live(s: &ArtSettings, length: f64) -> bool { + s[6] == 0.0 && guides_fit(s, length) } /// The brush named `name` of kind `kind` from its parameters `params`. @@ -342,15 +421,22 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< Tool::Art => { // The older form's 8 numbers aren't known: its brush keeps the defaults and its // paths their drawn look. The count tells the form, not the first number. - // Any other count isn't a form this reads. - let (colorization, plain) = if let Ok(n15) = <&[f64; 15]>::try_from(n.as_slice()) { - (if n15[1] == 1.0 { Colorization::Tints } else { Colorization::None }, art_is_plain(n15)) + // Any other count isn't a form this reads. Settings that aren't read keep the + // defaults but the colorization, and their paths their drawn look. + let (brush, settings) = if let Ok(&[first, ref settings @ ..]) = <&[f64; 15]>::try_from(n.as_slice()) { + match art_options(settings).filter(|_| first == 1.0) { + Some(brush) => (brush, Some(*settings)), + None => { + let colorization = if settings[0] == 1.0 { Colorization::Tints } else { Colorization::None }; + (ArtBrush { colorization, ..ArtBrush::default() }, None) + } + } } else if n.len() == 8 { - (ArtBrush::default().colorization, false) + (ArtBrush::default(), None) } else { return Err(BrushError::Values); }; - DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain } + DraftKind::Art { brush, art: art()?, settings } } _ => { let [_, _, p3, ..] = n.as_slice() else { return Err(BrushError::Values) }; @@ -453,10 +539,15 @@ pub(super) fn finish_brushes_within( _ if !kept => None, DraftKind::Calligraphic(c) => Some(BrushKind::Calligraphic(c)), DraftKind::Scatter { brush, art: name } => copy(&name).map(|a| BrushKind::Scatter(Scatter { art: a, ..brush })), - DraftKind::Art { brush, art: name, .. } => copy(&name).map(|mut a| { + DraftKind::Art { mut brush, art: name, settings } => copy(&name).map(|mut a| { // The side of the section with y up draws on the right of the path, and a brush // draws art's y (down here) on the left of the path. a.transform(Affine::scale_non_uniform(1.0, -1.0), false); + if let ArtScale::BetweenGuides { start, end } = &mut brush.scale { + // Guides that leave no art between them stretch the whole art. + let length = a.geometric_bounds().map_or(0.0, |b| b.width()); + (*start, *end) = settings.and_then(|s| guides(&s, length)).unwrap_or((0.0, 1.0)); + } BrushKind::Art(ArtBrush { art: a, ..brush }) }), DraftKind::Pattern { .. } => None, @@ -586,8 +677,8 @@ pub(crate) struct BrushRead { pub brush: Brush, /// A path whose art style names the brush, at the brush's own settings, opens with the brush on /// its stroke: a Calligraphic brush, or an Art brush whose art an Art brush draws as the file - /// does ([`art_bends`]) and whose numbers are those of a brush that stretches its art along the - /// path, unflipped, at 100% in Tints. + /// does ([`art_bends`]) and whose settings VectorCraft draws as the file does ([`art_options`], + /// with guides that leave art between them and without Scale Proportionately: [`opens_live`]). pub live: bool, } @@ -604,7 +695,9 @@ pub(crate) fn read_brush_object(plugin: &str, name: &str, params: &str, art: Opt let live = kept(&drafts, §ions).first().copied().unwrap_or(false) && match &drafts[0].kind { DraftKind::Calligraphic(_) => true, - DraftKind::Art { plain: true, art: section, .. } => bending_art(&drafts, §ions).contains(section), + DraftKind::Art { settings: Some(s), art: section, .. } => { + bending_art(&drafts, §ions).get(section).is_some_and(|length| opens_live(s, *length)) + } _ => false, }; let [draft] = drafts; @@ -643,8 +736,8 @@ pub(crate) struct BrushesRead { /// The brush a stroke that names a brush is drawn with, by name: the first one kept /// ([`kept`]), as an index in [`Self::drafts`]. by_name: HashMap, - /// The art an Art brush draws as the file does ([`bending_art`]). - bending: HashSet, + /// The art an Art brush draws as the file does, with its length ([`bending_art`]). + bending: HashMap, } impl BrushesRead { @@ -681,7 +774,7 @@ impl BrushesRead { st.items.iter().all(|item| match item { StyleItem::Stroke { brush: Some((name, settings)), .. } => { let Some(draft) = self.by_name.get(name).and_then(|i| self.drafts.get(*i)) else { return false }; - draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art) && !st.effects.is_empty()) + draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art(_)) && !st.effects.is_empty()) } _ => true, }) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index b77bc32199..e0f08341ec 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -437,8 +437,8 @@ struct BrushesKept { read: (usize, usize), /// Which of [`Reader::brush_drafts`] are kept as brushes. kept: Vec, - /// The art an Art brush draws as the file does. - bending: HashSet, + /// The art an Art brush draws as the file does, with its length. + bending: HashMap, } struct Reader<'a> { diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 911d8340cc..f86ee6603b 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1181,7 +1181,7 @@ mod tests { assert!(nodes > 0); let draft = |i: usize| brush::BrushDraft { name: format!("Brush {i}"), - kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), plain: false }, + kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), settings: None }, }; let drafts: Vec<_> = (0..5).map(draft).collect(); let (kept, left_out) = brush::finish_brushes_within(drafts.clone(), &reader.brush_art, None, 3 * nodes); diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 50a356e524..6952358243 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -992,8 +992,8 @@ struct StylePaints<'a> { brushes: &'a [super::super::brush::BrushDraft], /// Which of [`Self::brushes`] are kept as brushes ([`super::super::brush::kept`]). kept: &'a [bool], - /// The art an Art brush draws as the file does ([`super::super::brush::bending_art`]). - bending: &'a HashSet, + /// The art an Art brush draws as the file does, with its length ([`super::super::brush::bending_art`]). + bending: &'a HashMap, space: Affine, } @@ -1242,7 +1242,7 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p if !super::super::brush::draws_as(draft, settings, paints.bending) { return None; } - let art = matches!(settings, super::super::brush::PathBrush::Art); + let art = matches!(settings, super::super::brush::PathBrush::Art(_)); let on_a_path = matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); if !on_a_path && (art || !slot) { return None; @@ -1542,7 +1542,7 @@ mod tests { let st = &styles["Inked"]; let brushes = [parse_tool_object(Tool::Calligraphic, "N", "1 4 8 30 50 -150 210 2 5 6 0").unwrap()]; let (gradients, patterns, names, v7_patterns, bending) = - (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new()); + (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashMap::new()); let paints = StylePaints { gradients: &gradients, patterns: &patterns, @@ -1588,7 +1588,7 @@ mod tests { // GUARDS: an image with a Warp in its art style came in as its drawn look. let warp = ("Adobe Deform", "1 /Int (DeformStyle) ,\n0.4 /Real (DeformValue) ,\n"); // brand-ok: the filter's name in the files let (gradients, patterns, names, v7_patterns, bending) = - (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new()); + (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashMap::new()); let paints = StylePaints { gradients: &gradients, patterns: &patterns, diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index d27a297925..891c058bb6 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -1500,11 +1500,30 @@ const ART_ENTRIES: [&str; 18] = [ "Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files ]; -/// An Art brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a color, names -/// no Art brush, holds an entry that isn't read, or its settings aren't those of art that stretches -/// along the path, unflipped, at the brush's own width (`Scaling` 1), in Tints (`Colorization` 1), -/// with `FoldAdjustment` 1, which is [`vectorcraft_brush::ArtBrush`]'s `overlap: false`: its drawn -/// look stays. +/// The entries of an Art brush's filter that hold its settings, in the order of +/// [`super::brush::ArtSettings`], with how each is written and its value when the filter leaves it +/// out (`None`: the filter must hold it). +const ART_SETTINGS: [(&str, Kind, Option); 14] = [ + ("Colorization", Kind::Int, None), + ("Scaling", Kind::Real, None), + ("Spacing", Kind::Real, Some(0.0)), + ("Adjustment", Kind::Int, Some(1.0)), + ("Counterclockwise", Kind::Int, Some(0.0)), + ("UpsideDown", Kind::Int, Some(0.0)), + ("Proportional", Kind::Bool, Some(0.0)), + ("MinScaling", Kind::Real, Some(1.0)), + ("MaxScaling", Kind::Real, Some(1.0)), + ("ScaleYDependsOn", Kind::Int, Some(0.0)), + ("Segmented", Kind::Bool, Some(0.0)), + ("StartSegmentLength", Kind::Real, Some(0.0)), + ("EndSegmentLength", Kind::Real, Some(0.0)), + ("FoldAdjustment", Kind::Bool, Some(1.0)), +]; + +/// An Art brush's filter `f` as the stroke it paints, with its settings: `None` when it isn't a +/// stroke in a color, names no Art brush, holds an entry that isn't read or a setting that isn't a +/// number of its kind, or tints its art with a swatch's color: its drawn look stays. Whether the +/// brush draws as these settings is [`super::brush::draws_as`]'s to say. fn art_stroke(f: &Filter) -> Option { if !f.paints.0 || f.gradient @@ -1519,28 +1538,13 @@ fn art_stroke(f: &Filter) -> Option { if plugin != ART_BRUSH { return None; } - let off = |key: &str, kind: Kind| matches!(entry(f, key, kind), Ok(None | Some(0.0))); - let plain = off("Proportional", Kind::Bool) - && off("Segmented", Kind::Bool) - && off("UpsideDown", Kind::Int) - && off("Counterclockwise", Kind::Int) - && off("ScaleYDependsOn", Kind::Int) - && off("Spacing", Kind::Real) - && off("StartSegmentLength", Kind::Real) - && off("EndSegmentLength", Kind::Real); - if !plain || entry(f, "Adjustment", Kind::Int).ok()?.unwrap_or(1.0) != 1.0 || entry(f, "FoldAdjustment", Kind::Bool).ok()?.unwrap_or(1.0) != 1.0 { - return None; - } - let one = |key: &str| entry(f, key, Kind::Real).is_ok_and(|v| v.unwrap_or(1.0) == 1.0); - if entry(f, "Scaling", Kind::Real).ok()?? != 1.0 || !one("MinScaling") || !one("MaxScaling") { - return None; - } - if entry(f, "Colorization", Kind::Int).ok()?? != 1.0 { - return None; + let mut settings = [0.0; 14]; + for (slot, (key, kind, missing)) in settings.iter_mut().zip(ART_SETTINGS) { + *slot = entry(f, key, kind).ok()?.or(missing)?; } let paint = f.stroke.clone()?; // The tinted pieces are process colors, so a swatch's ink would be lost. - if matches!(paint, Paint::Solid { swatch: Some(_), .. }) { + if settings[0] == 1.0 && matches!(paint, Paint::Solid { swatch: Some(_), .. }) { return None; } Some(StyleItem::Stroke { @@ -1552,7 +1556,7 @@ fn art_stroke(f: &Filter) -> Option { dash: None, profile: None, align: StrokeAlign::Center, - brush: Some((name.clone(), PathBrush::Art)), + brush: Some((name.clone(), PathBrush::Art(settings))), }) } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index daf99047f3..4ec272b26b 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -9721,7 +9721,7 @@ fn a_pattern_brushs_five_slots_are_kept() { fn an_older_art_brush_comes_in_with_its_art() { const OLD_RIBBON: &str = "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0"; let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", OLD_RIBBON).unwrap(); - assert!(matches!(d.kind, brush::DraftKind::Art { ref art, plain: false, .. } if art == "Ribbon art"), "{d:?}"); + assert!(matches!(d.kind, brush::DraftKind::Art { ref art, settings: None, .. } if art == "Ribbon art"), "{d:?}"); let brush::DraftKind::Art { ref brush, .. } = d.kind else { panic!("art") }; assert_eq!(brush.colorization, vectorcraft_brush::ArtBrush::default().colorization, "the default colorization"); // A count of numbers neither form has isn't read. @@ -9731,8 +9731,8 @@ fn an_older_art_brush_comes_in_with_its_art() { assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0"), Err(brush::BrushError::Values)); // 8 numbers are the older form whatever the first number says: read with the defaults. let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", "0 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0").unwrap(); - let brush::DraftKind::Art { ref brush, ref art, plain } = d.kind else { panic!("art") }; - assert_eq!((art.as_str(), plain, brush.colorization), ("Ribbon art", false, vectorcraft_brush::ArtBrush::default().colorization)); + let brush::DraftKind::Art { ref brush, ref art, settings } = d.kind else { panic!("art") }; + assert_eq!((art.as_str(), settings, brush.colorization), ("Ribbon art", None, vectorcraft_brush::ArtBrush::default().colorization)); let objects = [plugin_object(ART_BRUSH, "Ribbon", OLD_RIBBON), plugin_object(ART_BRUSH, "Bare", "1 / / / / / / 0 1 / 1 0 1 0 1 0 0 0")].concat(); let r = read_ok(&format!("{}{objects}{}", brush_art("Ribbon art", 4.0), layer(""))); let lib = vectorcraft_brush::library(&r.doc); @@ -10395,9 +10395,109 @@ fn an_art_brush_bends_only_paths_in_solid_fills() { assert!(wrong.is_empty(), "{wrong:?}"); } +/// The entries of an Art brush's filter that hold its settings, in the order of its object's numbers 2 +/// to 15, with how each is written. +const ART_KEYS: [(&str, &str); 14] = [ + ("Colorization", "Int"), + ("Scaling", "Real"), + ("Spacing", "Real"), + ("Adjustment", "Int"), + ("Counterclockwise", "Int"), + ("UpsideDown", "Int"), + ("Proportional", "Bool"), + ("MinScaling", "Real"), + ("MaxScaling", "Real"), + ("ScaleYDependsOn", "Int"), + ("Segmented", "Bool"), + ("StartSegmentLength", "Real"), + ("EndSegmentLength", "Real"), + ("FoldAdjustment", "Bool"), +]; + +/// The Art brush `Ribbon` (its art 40 long: [`lopsided_art`]) whose settings, numbers 2 to 15 of its +/// object, are those of [`RIBBON`] with `changes` (index in the 14, value), and the style `Inked` that +/// gives a path the same settings. +fn ribbon_with(changes: &[(usize, &str)]) -> (String, String) { + let mut settings = ["1", "1", "0", "1", "0", "0", "0", "1", "1", "0", "0", "0", "0", "1"]; + for (i, v) in changes { + settings[*i] = v; + } + let brush = format!("2 / Ribbon art/ / / / / 0 0/ 5 0 0 0 / 1 {}", settings.join(" ")); + let entries: Vec<(&str, &str, &str)> = ART_KEYS.iter().zip(settings).map(|((key, kind), value)| (*key, value, *kind)).collect(); + (format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", &brush)), art_style("Ribbon", 1.0, &entries, "")) +} + +/// A path with an Art brush whose settings are its own comes in brushed, the brush with those +/// settings: None, another width (the lowest and highest widths aren't drawn while the width is +/// fixed), flipped along or across, and Stretch Between Guides, whose guides come in as fractions of +/// the art as it runs along the path (measured before it is flipped along it, so a flip along swaps +/// them). Lengths without Stretch Between Guides aren't guides. A brush that scales its art +/// proportionately comes into the panel with that option, and its path keeps its drawn look. +#[test] +fn an_art_brush_with_options_comes_in_brushed_with_them() { + use vectorcraft_brush::{ArtBrush, ArtScale, Colorization}; + let check_file = |why: &str, (def, style): (String, String), want: &dyn Fn(&ArtBrush) -> bool| { + let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path()))); + let a = art(&r.doc); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{why}: the path, not its look: {a:#?}"); + assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon"), "{why}"); + let lib = vectorcraft_brush::library(&r.doc); + let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("{why}: art") }; + assert!(want(brush), "{why}: {brush:?}"); + r + }; + let check = |why: &str, changes: &[(usize, &str)], want: &dyn Fn(&ArtBrush) -> bool| check_file(why, ribbon_with(changes), want); + let near = |a: f64, b: f64| (a - b).abs() < 1e-9; + // Settings that don't draw may differ between the brush and the path: the lowest and highest + // width while the width is fixed, and lengths without Stretch Between Guides. + let (def, style) = ribbon_with(&[(1, "2"), (7, "2"), (8, "2")]); + let style = style.replace("2 /Real (MinScaling) ,\n", "").replace("2 /Real (MaxScaling) ,\n", ""); + check_file("the path leaves out the widths the pen would give", (def, style), &|b| b.width == 200.0); + let (def, _) = ribbon_with(&[]); + let (_, style) = ribbon_with(&[(11, "5"), (12, "10")]); + check_file("lengths on the path alone", (def, style), &|b| b.scale == ArtScale::Stretch); + check("its art's colors", &[(0, "0")], &|b| b.colorization == Colorization::None); + check("twice as wide", &[(1, "2"), (7, "2"), (8, "2")], &|b| b.width == 200.0); + check("half as wide, the pen's widths left", &[(1, "0.5")], &|b| b.width == 50.0 && b.width_mode.is_none()); + let along = check("flipped along", &[(4, "1")], &|b| b.flip_along && !b.flip_across); + let across = check("flipped across", &[(5, "1")], &|b| b.flip_across && !b.flip_along); + let (def, style) = ribbon_with(&[(6, "1")]); + let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path()))); + assert!(matches!(art(&r.doc)[0].kind, NodeKind::Group { .. }), "proportional: the drawn look"); + let lib = vectorcraft_brush::library(&r.doc); + let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("proportional: art") }; + assert_eq!(brush.scale, ArtScale::Proportional); + check( + "between guides", + &[(10, "1"), (11, "5"), (12, "10")], + &|b| matches!(b.scale, ArtScale::BetweenGuides { start, end } if near(start, 5.0 / 40.0) && near(end, 30.0 / 40.0)), + ); + check("between guides, flipped along", &[(10, "1"), (11, "5"), (12, "10"), (4, "1")], &|b| { + b.flip_along && matches!(b.scale, ArtScale::BetweenGuides { start, end } if near(start, 10.0 / 40.0) && near(end, 35.0 / 40.0)) + }); + check("lengths without guides", &[(11, "5"), (12, "10")], &|b| b.scale == ArtScale::Stretch); + // Along a straight line, y down: unflipped, the wide block is above the line and the narrow one + // below it, at the start. + let line = kurbo::BezPath::from_vec(vec![kurbo::PathEl::MoveTo(Point::new(0.0, 0.0)), kurbo::PathEl::LineTo(Point::new(100.0, 0.0))]); + let filled = |r: &Structure, x: f64, y: f64| { + let lib = vectorcraft_brush::library(&r.doc); + let st = art(&r.doc)[0].appearance.stroke().cloned().unwrap(); + let ribbon = lib.iter().find(|b| b.name == "Ribbon").unwrap(); + let pieces = vectorcraft_brush::stroke_pieces(&r.doc, ribbon, &line, &st); + pieces.iter().any(|n| { + let mut hit = false; + n.walk(&mut |c| hit |= c.path_data().is_some_and(|p| kurbo::Shape::winding(&p.to_bezpath(), Point::new(x, y)) != 0)); + hit + }) + }; + // At weight 1 the art is 8 across: each block is 4 across, on its side of the line. + assert!(filled(&across, 50.0, 2.0) && filled(&across, 10.0, -2.0) && !filled(&across, 50.0, -2.0), "across: the wide block below"); + assert!(filled(&along, 50.0, -2.0) && filled(&along, 90.0, 2.0) && !filled(&along, 10.0, 2.0), "along: the narrow block at the end"); +} + /// An Art brushed path keeps its drawn look when the brush wouldn't draw it the way the file does: -/// its settings aren't those of art that stretches along the path at the brush's own width and -/// color, the brush's own aren't, its art isn't read, or the brush isn't one the panel keeps. +/// its settings differ from the brush's, the brush's are ones VectorCraft doesn't draw as the file +/// does, its art isn't read, or the brush isn't one the panel keeps. #[test] fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { let good = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON)); @@ -10440,21 +10540,7 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { ), plain_style.clone(), ), - ( - "a width other than the brush's 100%", - format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", wide)), - art_style( - "Ribbon", - 1.0, - &STRETCHED.map(|e| if matches!(e.0, "Scaling" | "MinScaling" | "MaxScaling") { (e.0, "2", e.2) } else { e }), - "", - ), - ), - ( - "a brush that keeps its art's colors", - format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", own_colors)), - art_style("Ribbon", 1.0, &STRETCHED.map(|e| if e.0 == "Colorization" { (e.0, "0", e.2) } else { e }), ""), - ), + ("a brush that keeps its art's colors, the path in Tints", format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", own_colors)), plain_style.clone()), ("an entry that isn't read", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "1 /Int (SomeOtherSetting) ,\n")), ( "an effect on the path", @@ -10467,7 +10553,6 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { ("proportional", good.clone(), style_with("Proportional", "1")), ("between guides", good.clone(), style_with("Segmented", "1")), ("its own width", good.clone(), style_with("Scaling", "2")), - ("a width that varies", good.clone(), style_with("MaxScaling", "2")), ("a width on the pen", good.clone(), style_with("ScaleYDependsOn", "2")), ("spacing", good.clone(), style_with("Spacing", "1")), ("flipped", good.clone(), style_with("UpsideDown", "1")), @@ -10477,7 +10562,6 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { ("its own colorization", good.clone(), style_with("Colorization", "0")), ("tints and shades", good.clone(), style_with("Colorization", "2")), ("hue shift", good.clone(), style_with("Colorization", "3")), - ("start of a guide", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "5 /Real (StartSegmentLength) ,\n")), ("variable width", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "2 /Int (BSWidthSource) ,\n")), ( "width arrays", @@ -10494,6 +10578,28 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { ("art not read", plugin_object(ART_BRUSH, "Ribbon", RIBBON), plain_style.clone()), ("no such brush", good.clone(), art_style("Other", 1.0, &STRETCHED, "")), ]; + // The brush's own settings, which the path shares, but VectorCraft doesn't draw as the file does. + let own = [ + ("both brush and path: tints and shades", &[(0, "2")][..]), + ("both: hue shift", &[(0, "3")]), + ("both: spacing", &[(2, "1")]), + ("both: another adjustment", &[(3, "2")]), + ("both: no fold adjustment", &[(13, "0")]), + ("both: a width on the pen", &[(9, "1"), (7, "0.5"), (8, "2")]), + ("both: a width past the range", &[(1, "20"), (7, "20"), (8, "20")]), + ("both: a flip of another value", &[(4, "2")]), + ("both: proportional", &[(6, "1")]), + ("both: proportional and between guides", &[(6, "1"), (10, "1"), (11, "5"), (12, "10")]), + ("both: guides that leave no art between them", &[(10, "1"), (11, "30"), (12, "10")]), + ("both: a guide past the start", &[(10, "1"), (11, "-5"), (12, "10")]), + ]; + let cases: Vec<(&str, String, String)> = cases + .into_iter() + .chain(own.iter().map(|(why, changes)| { + let (def, style) = ribbon_with(changes); + (*why, def, style) + })) + .collect(); let mut wrong = vec![]; for (why, def, style) in cases { let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path()))); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d6816744fb..0a40bdbca9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -116,15 +116,16 @@ private editing data. VectorCraft: bytes and past 4,096 styles are left out with a warning. A panel with no style written opens with a new document's styles, with a warning. No art style is written on a large canvas. The Brushes panel's Calligraphic brushes, and its Scatter and Art brushes with their art, are written in its order: Scatter and Art brushes in Tints or keeping their - art's colors, Art brushes that stretch their art along the path from left to right at 100% unflipped without - overlap, and art of paths and compound paths with points and groups with members, with one solid fill and one - plain stroke each. Other brushes, + art's colors, Art brushes that run their art from left to right without overlap at a fixed width, with their + width, flips and scale (Stretch Between Guides with guides that leave art between them), and art of paths and + compound paths with points and groups with members, with one solid fill and one plain stroke each. Other brushes, Pattern and Bristle brushes among them, and brushes past the number or the art VectorCraft's reader reads are left out with a warning; no brush is written on a large canvas. A path or compound path with a Calligraphic brush, or - with an Art brush in Tints whose art bends along the path, keeps the brush on its stroke; other brushed strokes are + with an Art brush whose art bends along the path and that doesn't scale its art proportionately, keeps the brush + on its stroke; other brushed strokes are written as their brush's art. A document that uses a new document's brushes writes none until a path keeps one on - its stroke; then the brushes are written, and the file opens without the new document's Pattern and Bristle brushes - and its Art brush with other options. + its stroke; then the brushes are written, and the file opens without the new document's Pattern and Bristle + brushes. Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, @@ -264,7 +265,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 (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), 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"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), 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, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), 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 on a Stroke Offset stroke or written as each side's widths) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way when nothing in the object is stroked: up to 180° either way (a limit VectorCraft sets) on a path or compound path whose segments are all straight, and up to 90° either way (a limit VectorCraft sets) on another path, compound path or group with no guide in it. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), 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 variable-width stroke with one centered width array, dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. 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), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. 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. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; 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). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), 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 editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. 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 (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), 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"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), 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, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), 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 on a Stroke Offset stroke or written as each side's widths) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [layered image file format specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way when nothing in the object is stroked: up to 180° either way (a limit VectorCraft sets) on a path or compound path whose segments are all straight, and up to 90° either way (a limit VectorCraft sets) on another path, compound path or group with no guide in it. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), 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 variable-width stroke with one centered width array, dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. 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), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush is in Tints or None, at a fixed width from 1% to 1,000%, stretched to the path or stretched between guides that leave art between them, flipped along or across or not ([Art brush options](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf), printed page 193), with `Spacing` 0, `Adjustment` 1 and `FoldAdjustment` 1 (VectorCraft's own reading of these numbers; a brush that scales its art proportionately comes into the Brushes panel with that option, and its paths keep their drawn look); the path's settings are the brush's own (apart from the lowest and highest width of a fixed width and the guides' lengths without Stretch Between Guides, which don't draw), with no entry that isn't read, no variable width and no effects; a stroke in Tints isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. 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. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), 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 detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) 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 EPS with editing data 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; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; 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). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), 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 live objects that only the editing data holds 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 (the 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 editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. 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. A run of type in the editing data whose baseline direction is 1 opens with Standard Vertical Roman Alignment (https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) on, which is VectorCraft's own mapping: its half-width characters stand upright in vertical type, and horizontal type keeps the mark with no change to its layout. A run whose baseline direction is 2 or 3 (Tate-chu-yoko), or that names none in the run, its character style or the document's defaults, opens with it off. The scripting reference lists 1, 2 and 3 as the Standard, vertical rotated and Tate-chu-yoko baseline directions (https://web.archive.org/web/20170621180804/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20VBScript%20Reference.pdf, page 256). A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. diff --git a/docs/gaps.md b/docs/gaps.md index 201823b983..3e563e9d2c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -212,10 +212,12 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi 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. - Paths with an Art brush open the same way when the brush stretches art of solid-filled paths along the path, - unflipped, at 100% in Tints, and the path has none of its own settings, variable width, effects or a swatch - stroke. Other Art brushes (None or another colorization, another width, Proportional, between guides, flipped, - art with strokes, gradients or patterns) and type with an Art brush keep the drawn look. + Paths with an Art brush open the same way when the brush bends art of solid-filled paths along the path, and + the path has none of its own settings, variable width, effects or a swatch stroke in Tints. The brush may be + in None or Tints, at a fixed width, stretched to the path or between guides, and flipped along or across. + Other Art brushes (Tints and Shades or Hue Shift, a width that follows the pen, Scale Proportionately, guides + that leave no art between them, a `Spacing`, `Adjustment` or `FoldAdjustment` other than 0, 1 and 1, art with + strokes, gradients or patterns) and type with an Art brush keep the drawn look. - **Done (2026-11-12):** Explicitly requested editing data retains a single artboard larger than a capped 14,400 pt PDF page (#6865); the default import and other size mismatches remain unchanged. Synthetic importer regression; scores unchanged. - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ee72f2cbe8..ea0d100fb4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1661,7 +1661,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, an object in a pattern's art or a brush's art with an art style VectorCraft reads opens as itself with its effects live, as an object on a layer does, and an object in a pattern's art keeps the id of a graphic style in the file's list (`crates/eps/src/import/ai/tests.rs`). A graph in a `.ai` whose records have the shape and values the published version 7 specification gives ([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), printed pp. 109–112 and 146–158) opens as a live grouped column, stacked column, line or area graph with its saved art, table, axes and settings, and Graph Data edits it ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-data.html)); other graphs keep their art as plain objects with a note, and saving a graph to `.ai` warns that its data and settings aren't written (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/graph/tests.rs`). Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). - A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in None or Tints keeps that colorization, and one in the newer form also keeps its fixed width, Flip Along and Flip Across, and Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with its guides ([Art brush options](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf), printed page 193). Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form that bends art of solid-filled paths with those options, open as the path with the brush on its stroke, except paths whose brush scales its art proportionately. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` or EPS opened from its editing data, a Scatter brush whose last 15 numbers start with 1 comes into the Brushes panel in Tints, and each brush art section is read without its frame, an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt). A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group (`crates/eps/src/import/ai/tests.rs`). In a `.ai` opened from its editing data, type with Warp on the object or on one of its fills or strokes opens as type with the effect live when the live warp draws as the art saved with it. When it draws differently, or when the file's limit of checks has no check left for it, it opens as that art, and one note says how many did. Type stays live without a comparison when the saved art paints nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text object doesn't come in as one text object (`crates/eps/src/import/ai/envelope.rs`, `crates/eps/src/import/tests_native.rs`). From 991c36458a258de43f406256f8624feec3196fd7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:03:35 -0800 Subject: [PATCH 2/3] .ai import: an Art brushed path opens live only when its own settings would A path's Art brush settings matched the brush's within a small tolerance, and only the brush's were checked. A path with a Spacing of 0.0005, or guides that leave no art between them, opened live with the brush's settings. The path's settings now pass the same checks as the brush's, and such a path keeps its drawn look. --- crates/eps/src/import/ai/brush.rs | 7 +++++-- crates/eps/src/import/ai/tests.rs | 8 ++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index b995b1bda0..58dac58c92 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -277,7 +277,8 @@ pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap) -> /// Does the brush `draft` draw as `settings`, 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. An Art brush's settings that don't draw ([`drawn`]) may differ. +/// has no settings of its own. An Art brush's settings that don't draw ([`drawn`]) may differ, and +/// the path's own settings must be ones the brush would open with ([`art_options`], [`opens_live`]). /// `bending`: [`bending_art`]. pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashMap) -> bool { let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0); @@ -290,7 +291,9 @@ pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashM && d.modes() == c.modes() } (DraftKind::Art { settings: Some(d), art: name, .. }, PathBrush::Art(s)) => { - drawn(d).iter().zip(drawn(s)).all(|(a, b)| close(*a, b)) && bending.get(name).is_some_and(|length| opens_live(d, *length)) + drawn(d).iter().zip(drawn(s)).all(|(a, b)| close(*a, b)) + && art_options(s).is_some() + && bending.get(name).is_some_and(|length| opens_live(d, *length) && opens_live(s, *length)) } _ => false, } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index feaa4201f6..2701cb6fec 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -10601,7 +10601,8 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { ("its own width", good.clone(), style_with("Scaling", "2")), ("a width on the pen", good.clone(), style_with("ScaleYDependsOn", "2")), ("spacing", good.clone(), style_with("Spacing", "1")), - ("flipped", good.clone(), style_with("UpsideDown", "1")), + ("a spacing close to the brush's 0", good.clone(), style_with("Spacing", "0.0005")), + ("flipped",good.clone(), style_with("UpsideDown", "1")), ("flipped the other way", good.clone(), style_with("Counterclockwise", "1")), ("another adjustment", good.clone(), style_with("Adjustment", "2")), ("no fold adjustment", good.clone(), style_with("FoldAdjustment", "0")), @@ -10639,13 +10640,16 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { ("both: guides that leave no art between them", &[(10, "1"), (11, "30"), (12, "10")]), ("both: a guide past the start", &[(10, "1"), (11, "-5"), (12, "10")]), ]; - let cases: Vec<(&str, String, String)> = cases + let mut cases: Vec<(&str, String, String)> = cases .into_iter() .chain(own.iter().map(|(why, changes)| { let (def, style) = ribbon_with(changes); (*why, def, style) })) .collect(); + // The brush's guides leave art between them, and the path's, close to them, leave none. + let (def, style) = ribbon_with(&[(10, "1"), (11, "20"), (12, "19.99")]); + cases.push(("path guides close to the brush's that leave no art between them", def, style.replace("19.99 /", "20 /"))); let mut wrong = vec![]; for (why, def, style) in cases { let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path()))); From 0a68935bf2b7314e388ae8ea0ed28525f5bbaccd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:35:50 -0800 Subject: [PATCH 3/3] .ai import: Art brush guides match as fractions of the art too A path's guide lengths matched the brush's within 0.001, so on very short art guides at 30% and 35% of the art matched. The guides now also match as fractions of the art's length. --- crates/eps/src/import/ai/brush.rs | 13 ++++++++++--- crates/eps/src/import/ai/tests.rs | 4 ++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 58dac58c92..1180e42fa9 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -291,9 +291,16 @@ pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashM && d.modes() == c.modes() } (DraftKind::Art { settings: Some(d), art: name, .. }, PathBrush::Art(s)) => { - drawn(d).iter().zip(drawn(s)).all(|(a, b)| close(*a, b)) - && art_options(s).is_some() - && bending.get(name).is_some_and(|length| opens_live(d, *length) && opens_live(s, *length)) + let (dd, ds) = (drawn(d), drawn(s)); + art_options(s).is_some() + && bending.get(name).is_some_and(|length| { + // The guides match as lengths and as fractions of the art's length, so they differ + // neither on short art nor near the ends of long art. + dd.iter().zip(ds).all(|(a, b)| close(*a, b)) + && [11, 12].iter().all(|&i| close(dd[i] / length, ds[i] / length)) + && opens_live(d, *length) + && opens_live(s, *length) + }) } _ => false, } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 2701cb6fec..8d20db92d1 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -10650,6 +10650,10 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { // The brush's guides leave art between them, and the path's, close to them, leave none. let (def, style) = ribbon_with(&[(10, "1"), (11, "20"), (12, "19.99")]); cases.push(("path guides close to the brush's that leave no art between them", def, style.replace("19.99 /", "20 /"))); + // On art 0.01 long, a guide at 0.0035 is 35% of the way along it, and the brush's at 0.003 is 30%. + let (def, style) = ribbon_with(&[(10, "1"), (11, "0.003"), (12, "0.002")]); + let short = format!("%AI8_BeginBrushPattern\n(Ribbon art)\n0 A\n0 g\n{}f\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 0.01, 4.0)); + cases.push(("path guides elsewhere on short art", def.replace(&lopsided_art("Ribbon art"), &short), style.replace("0.003 /", "0.0035 /"))); let mut wrong = vec![]; for (why, def, style) in cases { let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path())));