mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 00:40:57 -06:00
EPS/.ai editing data: art-style strokes aligned inside or outside keep their alignment
Squashed from #6010 onto main.
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user