diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 47fdc34fe..8e1f27bad 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -28,7 +28,11 @@ //! [`vectorcraft_brush::ArtBrush`]'s `overlap: false`). //! - A Scatter, Art or Pattern brush's parameters are fields split by `/`: a number, the art's //! section names (one for Scatter, five for Art and Pattern, empty where there is none, each -//! after a space for Art and Pattern), more fields, and last 15 numbers. +//! after a space for Art and Pattern), more fields, and last 15 numbers. Older files write +//! brushes with first number 1, one field fewer before the numbers and 8 numbers; an Art brush +//! with 8 numbers, whatever its first number, is read from its art's name alone (its numbers +//! aren't known, so it keeps the defaults and its paths their drawn look). A Pattern brush needs +//! only its first 3 numbers (its scale is the third). //! - 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). @@ -312,11 +316,10 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< }) .collect(); let n = fields.last().and_then(|f| numbers(f)).ok_or(BrushError::Values)?; - let n: &[f64; 15] = n.as_slice().try_into().map_err(|_| BrushError::Values)?; - let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n; let art = || tiles.first().cloned().flatten().ok_or(BrushError::NoArt); match kind { Tool::Scatter => { + let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n.as_slice() else { return Err(BrushError::Values) }; let pct = |a: f64, b: f64| (a * 100.0, b * 100.0); let values = [pct(*p2, *p3), pct(*p5, *p6), pct(*p8, *p9), (*p11, *p12)]; let codes = [*p4, *p7, *p10, *p13]; @@ -336,10 +339,20 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< DraftKind::Scatter { brush, art: art()? } } Tool::Art => { - let colorization = if *p2 == 1.0 { Colorization::Tints } else { Colorization::None }; - DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain: art_is_plain(n) } + // 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)) + } else if n.len() == 8 { + (ArtBrush::default().colorization, false) + } else { + return Err(BrushError::Values); + }; + DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain } } _ => { + let [_, _, p3, ..] = n.as_slice() else { return Err(BrushError::Values) }; let tiles: [Option; 5] = tiles.try_into().map_err(|_| BrushError::Values)?; DraftKind::Pattern { tiles, scale: p3 * 100.0 } } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index ffb950729..a7d483b13 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -8881,6 +8881,54 @@ fn a_pattern_brushs_five_slots_are_kept() { assert!(matches!(d.kind, brush::DraftKind::Art { ref art, .. } if art == "Ribbon art"), "{d:?}"); } +/// An Art brush in the older form (first number 1: one field fewer before its 8 numbers) is read +/// with the art of its section, but not as plain (its numbers aren't known); a Scatter brush in +/// that form isn't read (its values aren't all there). +#[test] +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:?}"); + 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. + for bad in ["1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0", "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0"] { + assert_eq!(brush::parse_tool_object(brush::Tool::Art, "Ribbon", bad), Err(brush::BrushError::Values), "{bad}"); + } + 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 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); + let vectorcraft_brush::BrushKind::Art(a) = brush_named(&lib, "Ribbon") else { panic!("art") }; + assert_eq!(a.art.geometric_bounds().map(|b| (b.width(), b.height())), Some((4.0, 4.0)), "the art of its section"); + assert_eq!(lib.len(), 1, "a brush naming no art is left out"); + assert!(r.warnings.iter().any(|w| w.ends_with("Brushes panel (1)")), "{:?}", r.warnings); +} + +/// A Pattern brush needs only its first 3 numbers, its scale the third (as a percentage), so the +/// older form's 8 numbers read too; fewer than 3 aren't read. +#[test] +fn a_pattern_brush_reads_its_scale_from_three_or_more_numbers() { + const SLOTS: &str = "1 / Tile one/ Tile two/ / / Tile five/ 0 1 / "; + for numbers in ["1 0 0.5", "1 0 0.5 0 1 0 0 0"] { + let d = brush::parse_tool_object(brush::Tool::Pattern, "Fence", &format!("{SLOTS}{numbers}")).unwrap(); + let brush::DraftKind::Pattern { tiles, scale } = d.kind else { panic!("{d:?}") }; + assert_eq!(tiles[1].as_deref(), Some("Tile two"), "{numbers}"); + assert!((scale - 50.0).abs() < 1e-9, "{numbers}: {scale}"); + } + for numbers in ["", "1", "1 0"] { + assert_eq!( + brush::parse_tool_object(brush::Tool::Pattern, "Fence", &format!("{SLOTS}{numbers}")), + Err(brush::BrushError::Values), + "{numbers:?}" + ); + } +} + /// A brush's art filled with a pattern the file defines before it is read, with that pattern. #[test] fn a_brushs_art_takes_the_patterns_defined_before_it() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6e1bc1fa4..600ecb039 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -312,6 +312,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | `.ai` and EPS import: Art brushes whose objects are in the older form (first number 1, one field fewer and 8 numbers at the end) come into the Brushes panel with their art instead of being left out with a note. In a survey of 302 users' EPS files (used locally, not committed), 652 Art brushes in 188 of the 197 files with the note were left out this way; afterwards 55 files have the note, all for Pattern brushes. Test `an_older_art_brush_comes_in_with_its_art` (crate `vectorcraft-eps`); scores unchanged | | 2026-11-06 | minor | EPS import: composite (Type 0) fonts with the 8/8 mapping (`FMapType` 2: PLRM 3rd ed., §5.10) draw a Type 42 descendant's glyphs from the TrueType outlines its `sfnts` carry (Adobe Technical Note #5012), spaced by their own widths or the show operator's numbers, and `charpath` and `stringwidth` read them; descendants found by name set their characters as type at the composite font's size instead of a thousand times it. EPS from `pdftops` of a PDF with embedded TrueType fonts showed nothing, or one black shape, where its type was. | | 2026-11-07 | minor | EPS and `.ai` editing data: opacity mask art that sets no paint of its own paints with the paint before the mask's dictionary, not the black a dictionary's state starts with. A logo of white letters masked by a rectangle with no colour no longer opens hidden: a user's `.ai` (used locally, not committed) shows its six logos. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: an art style's stroke aligned inside or outside its path (Stroke panel › Align Stroke, written as its own `Adobe Stroke Offset` filter) comes in as a stroke with that alignment, so objects whose style has one keep the style live instead of coming in as their drawn look. Without its inside/outside switch the drawn look stays. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3bcf7ac3c..9cad872b4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (`.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -1343,7 +1343,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`). 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`). 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. 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 at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke, 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 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`). 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`). @@ -1871,6 +1871,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; evidence updated; scores unchanged. | | 2026-11-08 | minor | Panel numeric wheel turns do not seed canvas zoom glides; corrected the disabled-field scroll description (#5949). Scores and estimates unchanged. | | 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. |