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