EPS/.ai editing data: art-style strokes aligned inside or outside keep their alignment

Squashed from #6010 onto main.
This commit is contained in:
Don Omondi
2026-11-09 02:03:01 +07:00
committed by bflatastic
parent b1f52d1369
commit d4f6fa0744
4 changed files with 163 additions and 24 deletions
+2 -2
View File
@@ -1189,9 +1189,9 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P
/// A style's stroke as an appearance item (`None` for a fill).
fn stroke_layer(item: &crate::import::ai::style::StyleItem) -> Option<AppearanceItem> {
let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, brush } = item else { return None };
let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, align, brush } = item else { return None };
let mut layer = StrokeLayer::new(paint.clone(), line_width(*width));
(layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone());
(layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile, layer.align) = (*cap, *join, *miter, dash.clone(), profile.clone(), *align);
layer.brush = brush.as_ref().map(|(name, _)| name.clone());
Some(AppearanceItem::Stroke(layer))
}
+55 -22
View File
@@ -36,8 +36,15 @@
//! Tool)` or `(Adobe ArtOnPath Brush Tool)`, with its stroke's style and the brush's settings for
//! the object as entries (see [`super::brush`]).
//!
//! A stroke aligned inside or outside its path (Stroke panel › Align Stroke,
//! https://helpx.adobe.com/illustrator/desktop/draw-and-paint/use-strokes/stroke-panel-options.html)
//! is its own basic filter too, `(Adobe Stroke Offset)`, with its stroke's style and
//! `StrokeOffsetInside` (1: inside, 0: outside, as its `DisplayString` names it) as entries.
//! Without that entry, or with it as anything but a `/Bool` 0 or 1, which side it paints isn't
//! known and the style isn't plain (its objects keep their drawn look).
//!
//! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills
//! with a pattern or gradient too; strokes with a Calligraphic or Art brush too), each with its own
//! with a pattern or gradient too; strokes with a Calligraphic or Art brush or aligned inside or outside too), each with its own
//! transparency and effects, and the effects of
//! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and
//! effects; others keep their drawn look. Effect › Pathfinder effects (`(Adobe Pathfinder)` in the
@@ -47,7 +54,7 @@ use std::collections::HashMap;
use serde_json::json;
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, WidthProfile};
use vectorcraft_doc::{Dash, Effect, Knockout, LineCap, LineJoin, StrokeAlign, WidthProfile};
use vectorcraft_effects::is_pathfinder;
use super::brush::PathBrush;
@@ -78,6 +85,8 @@ const BAS_RELIEF: &str = "PSAdapter_plugin_BsRl";
const SHAPE_EFFECTS: &str = "Adobe Shape Effects"; // brand-ok: the filter's name in the files
const PATHFINDER: &str = "Adobe Pathfinder"; // brand-ok: the filter's name in the files
/// A stroke aligned inside or outside its path (Stroke panel › Align Stroke).
const STROKE_OFFSET: &str = "Adobe Stroke Offset"; // brand-ok: the filter's name in the files
const CALLIGRAPHIC: &str = "Adobe Calligraphic Brush Tool"; // brand-ok: the filter's and plug-in's name in the files
const ART_BRUSH: &str = "Adobe ArtOnPath Brush Tool"; // brand-ok: the filter's and plug-in's name in the files
@@ -211,6 +220,8 @@ pub(super) enum StyleItem {
miter: f64,
dash: Option<Dash>,
profile: Option<WidthProfile>,
/// Which side of the path it paints (centred, or inside or outside a closed path).
align: StrokeAlign,
/// A brush: its name, and its settings for this object.
brush: Option<(String, PathBrush)>,
},
@@ -1211,29 +1222,49 @@ fn paint_item(f: &Filter) -> Result<Option<StyleItem>, ()> {
(None, None, Some(p)) => Ok(Some(StyleItem::Fill(p.clone()))),
(None, None, None) => Err(()),
},
STROKE if !f.paints.0 => Ok(None),
// Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end
// and the pattern, fitted, between them.
STROKE if f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) => Err(()),
STROKE => match &f.stroke {
Some(p) if !f.gradient && f.pattern.is_none() => Ok(Some(StyleItem::Stroke {
paint: p.clone(),
width: f.width,
cap: f.cap,
join: f.join,
miter: f.miter,
dash: f.dash.clone(),
profile: width_profile(&f.arrays),
brush: None,
})),
_ => Err(()),
},
STROKE => stroke_item(f, StrokeAlign::Center),
CALLIGRAPHIC => calligraphic_stroke(f).map(Some).ok_or(()),
STROKE_OFFSET => offset_stroke(f),
ART_BRUSH => art_stroke(f).map(Some).ok_or(()),
_ => Err(()),
}
}
/// A stroke filter `f`'s stroke, painting on side `align` of its path: `Ok(None)` when it paints
/// nothing, `Err` when it paints in a way this doesn't read.
fn stroke_item(f: &Filter, align: StrokeAlign) -> Result<Option<StyleItem>, ()> {
if !f.paints.0 {
return Ok(None);
}
// Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end
// and the pattern, fitted, between them.
if f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) {
return Err(());
}
match &f.stroke {
Some(p) if !f.gradient && f.pattern.is_none() => Ok(Some(StyleItem::Stroke {
paint: p.clone(),
width: f.width,
cap: f.cap,
join: f.join,
miter: f.miter,
dash: f.dash.clone(),
profile: width_profile(&f.arrays),
align,
brush: None,
})),
_ => Err(()),
}
}
/// A Stroke Offset filter `f`: a stroke whose own style is in its dictionary, aligned inside its
/// path when `StrokeOffsetInside` is 1 and outside when it is 0 (its `DisplayString` says which).
/// `Err` when that switch is missing or not a switch, or for a stroke [`paint_item`] doesn't read.
fn offset_stroke(f: &Filter) -> Result<Option<StyleItem>, ()> {
let inside = switch(f, "StrokeOffsetInside", Kind::Bool)?.ok_or(())?;
stroke_item(f, if inside { StrokeAlign::Inside } else { StrokeAlign::Outside })
}
/// A Calligraphic brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a
/// colour, names no Calligraphic brush, or its settings aren't all there as numbers. An angle
/// relative to the path isn't read.
@@ -1261,6 +1292,7 @@ fn calligraphic_stroke(f: &Filter) -> Option<StyleItem> {
miter: f.miter,
dash: None,
profile: None,
align: StrokeAlign::Center,
brush: Some((name.clone(), PathBrush::Calligraphic(super::brush::calligraphic(values, variation, codes)))),
})
}
@@ -1339,6 +1371,7 @@ fn art_stroke(f: &Filter) -> Option<StyleItem> {
miter: f.miter,
dash: None,
profile: None,
align: StrokeAlign::Center,
brush: Some((name.clone(), PathBrush::Art)),
})
}
@@ -1383,7 +1416,7 @@ impl Builder<'_> {
let c = &filters[k];
match c.name.as_str() {
_ if c.compound => {}
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => match paint_item(c) {
FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH => match paint_item(c) {
Ok(Some(StyleItem::Stroke { .. })) => under.strokes = true,
Ok(Some(StyleItem::Pattern { .. })) => under.patterns = true,
_ => {}
@@ -1397,7 +1430,7 @@ impl Builder<'_> {
let c = &filters[k];
match c.name.as_str() {
_ if c.compound => self.plain = false,
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH => {
FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH => {
paints += 1;
look.visible &= c.visible;
match paint_item(c) {
@@ -1518,7 +1551,7 @@ fn style(filters: &[Filter]) -> ArtStyle {
let f = &filters[id];
match f.name.as_str() {
STACK if f.compound => b.stack(id, &mut st),
FILL | STROKE | CALLIGRAPHIC | ART_BRUSH | CHAIN => {
FILL | STROKE | STROKE_OFFSET | CALLIGRAPHIC | ART_BRUSH | CHAIN => {
if let Some((item, look)) = b.item(id) {
st.items.push(item);
st.looks.push(look);
+105
View File
@@ -5778,6 +5778,111 @@ fn an_object_with_a_style_this_reads_comes_in_as_itself() {
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
/// An art style named `name`: a blue fill under a red 2 pt stroke written as a Stroke Offset filter
/// whose `StrokeOffsetInside` entry is `inside` (`None`: left out).
fn offset_stroke_style(name: &str, inside: Option<u8>) -> String {
let switch = inside.map(|v| format!("{v} /Bool (StrokeOffsetInside) ,\n")).unwrap_or_default();
offset_stroke_style_with(name, &switch, OFFSET_STROKE)
}
/// The Stroke Offset stroke of [`offset_stroke_style`]: red, 2 pt, butt caps, miter joins, solid.
const OFFSET_STROKE: &str = "0 R\n0 1 1 0 1 0 0 XA\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd";
/// An art style named `name`: a blue fill under a Stroke Offset filter whose dictionary holds
/// `entries` and the stroke style `stroke` (what follows `/StrokeStyle :`).
fn offset_stroke_style_with(name: &str, entries: &str, stroke: &str) -> String {
format!(
"/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Adobe Stroke Offset) 1 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n{entries}(Side) /String (DisplayString) ,\n\
/StrokeStyle : {stroke}\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n;\n /Part ,\n\
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n"
)
}
#[test]
fn a_stroke_offset_filter_is_a_stroke_aligned_inside_or_outside() {
// GUARDS: a style's stroke aligned inside or outside (Stroke panel › Align Stroke) left the object as its drawn look.
use vectorcraft_doc::StrokeAlign;
for (inside, align) in [(Some(1), Some(StrokeAlign::Inside)), (Some(0), Some(StrokeAlign::Outside)), (None, None), (Some(2), None)] {
let r = read_ok(&format!("%AI9_BeginArtStyles\n{}%AI9_EndArtStyles\n{}", offset_stroke_style("Edge", inside), layer(&styled_square("Edge"))));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let stroke = a[0].appearance.stroke().map(|s| (s.align, s.width, s.paint.color()));
match align {
Some(align) => {
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
assert_eq!(stroke, Some((align, 2.0, Some(Color::rgb(1.0, 0.0, 0.0)))));
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
// Without a 0/1 switch, which side it paints isn't known: the drawn look stays.
None => assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{inside:?}: {:?}", r.warnings),
}
}
}
/// A Stroke Offset stroke keeps the caps, joins, miter limit, dashes and width profile of its
/// style, as a Stroke Style Filter's does.
#[test]
fn a_stroke_offset_filter_keeps_its_strokes_style() {
use vectorcraft_doc::StrokeAlign;
let profile = "/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
/Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n";
let entries = format!("0 /Bool (StrokeOffsetInside) ,\n{profile}");
let stroke = "0 R\n0 1 1 0 1 0 0 XA\n1 J 2 j 3 w 4 M [5 10 ]0 d\n0 XR\n1 0 Xd";
let style = offset_stroke_style_with("Edge", &entries, stroke);
let r = read_ok(&format!("%AI9_BeginArtStyles\n{style}%AI9_EndArtStyles\n{}", layer(&styled_square("Edge"))));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
let s = a[0].appearance.stroke().expect("a stroke");
assert_eq!((s.align, s.width, s.cap, s.join, s.miter_limit), (StrokeAlign::Outside, 3.0, LineCap::Round, LineJoin::Bevel, 4.0));
assert_eq!(s.dash.as_ref().map(|d| d.pattern.clone()), Some(vec![5.0, 10.0]));
assert_eq!(s.profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)]));
}
/// A Stroke Offset stroke this doesn't read as a stroke keeps the object's drawn look: a switch
/// written as another kind, dashes aligned to corners and path ends, a gradient.
#[test]
fn a_stroke_offset_filter_with_a_stroke_this_does_not_read_keeps_the_drawn_look() {
let outside = "0 /Bool (StrokeOffsetInside) ,\n";
let gradient = "0 R\nBb\n0 0 0 0 Bh\n2 (Red ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 J 0 j 2 w 10 M []0 d\n0 XR\n1 0 Xd";
let cases = [
("a switch written as a number", offset_stroke_style_with("Edge", "1 /Int (StrokeOffsetInside) ,\n", OFFSET_STROKE)),
(
"dashes aligned to corners",
offset_stroke_style_with(
"Edge",
&format!("{outside}/Dictionary : /NotRecorded ,\n1 /Bool (BSDashAdj) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n"),
&OFFSET_STROKE.replace("[]0 d", "[5 10 ]0 d"),
),
),
("a gradient", offset_stroke_style_with("Edge", outside, gradient)),
];
for (why, style) in cases {
let r = read_ok(&format!("%AI9_BeginArtStyles\n{style}%AI9_EndArtStyles\n{}", layer(&styled_square("Edge"))));
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings);
}
}
/// A listed `[Default]` style whose stroke is aligned outside comes in as the default graphic
/// style with that stroke.
#[test]
fn a_default_style_with_a_stroke_aligned_outside_comes_in() {
use vectorcraft_doc::StrokeAlign;
let r =
read_ok(&format!("{}{}", styles_and_list(&[offset_stroke_style("[Default]", Some(0))], &["[Default]"]), layer(&styled_square("[Default]"))));
assert_eq!(style_names(&r.doc), [("[Default]".to_string(), 1)]);
let stroke = r.doc.graphic_styles[0].appearance.stroke().map(|s| (s.align, s.width));
assert_eq!(stroke, Some((StrokeAlign::Outside, 2.0)));
let a = art(&r.doc);
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
assert_eq!(a[0].extras().graphic_style, Some(1));
}
#[test]
fn a_symbol_defined_before_the_art_styles_reads_its_styled_objects() {
// GUARDS: objects in a symbol defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes
+1
View File
@@ -305,6 +305,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 | 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. |
| 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. |
| 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. |
| 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged |