Merge pull request #7367 from dotCooCoo/fix/ai-calligraphic-samples

.ai import: Calligraphic paths with pen samples keep their drawn look
This commit is contained in:
BFlat
2026-11-14 20:38:22 +07:00
committed by GitHub
8 changed files with 141 additions and 13 deletions
+2 -1
View File
@@ -18,7 +18,8 @@
//! 1 pt draws it at its own size), the brush by name (`/GObjRef : (name) (plug-in)
//! /PluginObject`), and the brush's settings for this path as entries: `Diameter`, `Roundness`,
//! `Angle`, their `…Variation` either side and `…DependsOn` codes, and `AngleRelativeTo` (0 the
//! page).
//! page). Samples of the pen may follow a path's own lines on `%_` lines (`(hex) Xt n XD`). They
//! aren't read, so such a path keeps its drawn look when a setting varies with the pen.
//! - A path with an Art brush is a stroke in its art style too: the filter
//! `(Adobe ArtOnPath Brush Tool)`, its stroke's paint and weight (the weight scales the art across
//! the path), the brush by name, and its settings for this path as entries: `Scaling`,
+5
View File
@@ -599,6 +599,10 @@ struct Reader<'a> {
compound_groups: HashSet<NodeId>,
/// Groups of a text object and its look (see [`Self::style_marker`]), which aren't a look.
text_looks: HashSet<NodeId>,
/// Objects followed by samples of the pen (`(…) Xt n XD`), which aren't read: such an object
/// whose style has a Calligraphic brush that varies with the pen keeps its drawn look (see
/// [`Self::style_marker`]).
pen_samples: HashSet<NodeId>,
/// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line.
mesh: Option<(super::mesh::MeshReader, bool)>,
/// The art styles (`%AI9_BeginArtStyles`), by name.
@@ -791,6 +795,7 @@ impl<'a> Reader<'a> {
ruler_guides: HashMap::new(),
compound_groups: HashSet::new(),
text_looks: HashSet::new(),
pen_samples: HashSet::new(),
group_ended: false,
mesh_ended: false,
layer_state: None,
+32 -5
View File
@@ -89,6 +89,18 @@ impl<'a> Reader<'a> {
}
}
/// `(…) Xt n XD`: samples of the pen along the object before it. They aren't read, so the object
/// keeps its drawn look when its style has a Calligraphic brush with a setting that varies with
/// the pen ([`Self::style_marker`]).
fn pen_samples_op(&mut self) {
if !self.after_object || self.pen_samples.len() >= MAX_NODES {
return;
}
if let Some(n) = self.frames.last().and_then(|f| f.children.last()) {
self.pen_samples.insert(n.id);
}
}
/// `/identifier (string) XT` (version 7 specification §7): a tag on the object before it. The
/// `AdobeURL` tag sets the object's URL, and every other tag is added to the object's data in
/// order, up to [`MAX_TAGS`]. A tag with no object before it, with an empty identifier, or with
@@ -418,6 +430,7 @@ impl<'a> Reader<'a> {
self.end_envelope();
}
}
"XD" => self.pen_samples_op(),
_ if IGNORED.contains(&w) => {}
// Before the art, the setup runs procedures of the printing format.
_ if self.frames.is_empty() => {}
@@ -559,9 +572,9 @@ impl<'a> Reader<'a> {
return;
}
self.image_tail = false;
// An object's tags (`XT`, and a graph's `Go`) follow it one after another, and an image's
// follow the `XH` that ends its data.
if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH" | "Go") {
// An object's tags (`XT`, and a graph's `Go`) and samples of the pen (`Xt`, `XD`) follow it
// one after another, and an image's tags follow the `XH` that ends its data.
if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH" | "Go" | "Xt" | "XD") {
return;
}
self.after_object = false;
@@ -688,6 +701,8 @@ impl<'a> Reader<'a> {
/// drawn look before it (all on `%_` lines inside another such object). Other markers end what
/// follows an object.
fn style_marker(&mut self, vals: &[V], hidden: bool) {
use crate::import::ai::brush::PathBrush;
use crate::import::ai::style::{ArtStyle, StyleItem};
let code = Self::nums(vals).last().copied().unwrap_or(0.0);
let style = vals.iter().rev().find_map(|v| self.names.text(v)).unwrap_or_default();
let after = std::mem::take(&mut self.after_object);
@@ -754,8 +769,20 @@ impl<'a> Reader<'a> {
} else {
look
};
// A style read here: the object, painted as it says.
let appearance = self.styles.get(&style).and_then(|st| {
// A style read here: the object, painted as it says. An object with samples of the pen
// keeps its drawn look when its style has a Calligraphic brush with a setting that varies
// with the pen: the samples aren't read, and the brush draws such a stroke at the pen's
// middle values.
let sampled = self.pen_samples.contains(&object.id);
let follows_pen = |st: &ArtStyle| {
st.items.iter().any(|i| match i {
StyleItem::Stroke { brush: Some((_, PathBrush::Calligraphic(c))), .. } => {
c.modes().iter().zip(c.variation).any(|(m, v)| m.follows_pen() && v != 0.0)
}
_ => false,
})
};
let appearance = self.styles.get(&style).filter(|st| !(sampled && follows_pen(st))).and_then(|st| {
styled(
object,
look,
+1 -2
View File
@@ -386,9 +386,8 @@ pub(super) const IGNORED: &[&str] = &[
// Path resolution (`Ar`, §12): how finely curves print.
"Ar",
"Xs",
// The samples of the pen that `XD` notes on the object before them (`Reader::pen_samples`).
"Xt",
// Five indexed curves of samples along a brushed path (`(…) Xt n XD`), on its hidden source path.
"XD",
"Xi",
"XI",
"LB2",
+28 -3
View File
@@ -1445,13 +1445,38 @@ fn offset_stroke(f: &Filter) -> Result<Option<StyleItem>, ()> {
stroke_item(f, if inside { StrokeAlign::Inside } else { StrokeAlign::Outside })
}
/// The entries a Calligraphic brush's filter may hold, with the settings [`calligraphic_stroke`]
/// reads. Any other entry keeps the drawn look.
const CALLIGRAPHIC_ENTRIES: [&str; 14] = [
"StrokeStyle",
"DisplayString",
"Brush",
"Diameter",
"DiameterVariation",
"DiameterDependsOn",
"Roundness",
"RoundnessVariation",
"RoundnessDependsOn",
"Angle",
"AngleVariation",
"AngleDependsOn",
"AngleRelativeTo",
"Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files
];
/// 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.
/// color, names no Calligraphic brush, holds an entry or a width that isn't read, or its settings
/// aren't all there as numbers. An angle relative to the path isn't read.
fn calligraphic_stroke(f: &Filter) -> Option<StyleItem> {
let real = |key: &str| entry(f, key, Kind::Real).ok().flatten();
let code = |key: &str| entry(f, key, Kind::Int).ok().flatten();
if !f.paints.0 || f.gradient || f.pattern.is_some() || f.repeated {
if !f.paints.0
|| f.gradient
|| f.pattern.is_some()
|| f.repeated
|| !f.arrays.is_empty()
|| !f.kinds.keys().all(|k| CALLIGRAPHIC_ENTRIES.contains(&k.as_str()))
{
return None;
}
if entry(f, "AngleRelativeTo", Kind::Int).ok()?.unwrap_or(0.0) != 0.0 {
+67 -1
View File
@@ -10304,20 +10304,86 @@ fn a_path_with_a_calligraphic_brush_comes_in_brushed() {
let pieces = vectorcraft_brush::stroke_pieces(&r.doc, &lib[0], &a[0].path_data().unwrap().to_bezpath(), st);
let height = pieces.iter().filter_map(|n| n.geometric_bounds()).map(|b| b.height()).fold(0.0, f64::max);
assert!(height > 40.0 && height < 46.0, "the line's rise and about half the nib's 3 pt: {height}");
// A filter with the version entry an effect's filter may carry still comes in brushed.
let versioned = style.replace("(Brush) ,\n", "(Brush) ,\n15 /Int (Adobe Effect Expand Before Version) ,\n");
let a = art(&read_ok(&format!("{def}{versioned}{}", layer(&brushed_path()))).doc);
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{a:#?}");
}
/// Samples of the pen along a path (`(…) Xt n XD`), as they follow its own lines.
const PEN_SAMPLES: &str = "%_(00FE64FE) Xt 0 XD\n%_(00006400) Xt 1 XD\n%_(00986498) Xt 2 XD\n%_(009E649E) Xt 3 XD\n%_(007F647F) Xt 4 XD\n";
/// A path with a Calligraphic brush that varies with the pen, followed by samples of the pen, comes
/// in as its drawn look alone, with the note that says so: the samples aren't read, and the brush
/// would draw it at the pen's middle values. Its own path doesn't come in beside the look. A brush
/// that doesn't follow the pen draws such a path the same without the samples, so it comes in
/// brushed.
#[test]
fn a_brushed_path_with_samples_of_the_pen_keeps_its_drawn_look() {
// GUARDS: the path came in beside its look, unpainted, and the note was missing.
let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D);
let style = brushed_style("Six D", 0.5, SIX_D_SETTINGS, [2, 5, 6], 0);
let path = brushed_path().replace("%_n\n", &format!("%_n\n{PEN_SAMPLES}"));
let r = read_ok(&format!("{def}{style}{}", layer(&path)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "the drawn look: {a:#?}");
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
// The samples belong to the path before them: a path after them is read as it was.
let after = format!("{path}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0));
let a = art(&read_ok(&format!("{def}{style}{}", layer(&after))).doc);
assert_eq!(a.len(), 2, "{a:#?}");
assert!(matches!(a[1].kind, NodeKind::Path { .. }) && a[1].appearance.fill().is_some(), "{a:#?}");
// Samples with no object before them in the layer mark nothing: the path after them is read.
let first = format!("{PEN_SAMPLES}{}", brushed_path());
let a = art(&read_ok(&format!("{def}{style}{}", layer(&first))).doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path, brushed: {a:#?}");
// A brush whose modes are Fixed and Random, one whose pen modes vary by 0, and an Art brush
// don't follow the pen: the path comes in brushed.
let fixed_def = plugin_object(CALLIGRAPHIC, "Six D", &SIX_D.replace(" 2 5 6 0", " 0 1 0 0"));
let fixed = brushed_style("Six D", 0.5, SIX_D_SETTINGS, [0, 1, 0], 0);
let even_def = plugin_object(CALLIGRAPHIC, "Six D", "1 6 6 40 40 30 30 2 5 6 0");
let even = brushed_style("Six D", 0.5, [(6.0, 0.0), (40.0, 0.0), (30.0, 0.0)], [2, 5, 6], 0);
for (why, def, style) in [("Fixed and Random", fixed_def, fixed), ("no variation", even_def, even)] {
let r = read_ok(&format!("{def}{style}{}", layer(&path)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{why}: {a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{why}: the path, brushed: {a:#?}");
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings);
}
let ribbon = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON));
let a = art(&read_ok(&format!("{ribbon}{}{}", art_style("Ribbon", 2.0, &STRETCHED, ""), layer(&path))).doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon"), "{a:#?}");
// A style with no brush doesn't draw the samples: the path takes it as it would without them.
let square = styled_square("Blue").replace("f\n1 (Blue) XW", &format!("f\n{PEN_SAMPLES}1 (Blue) XW"));
let r = read_ok(&format!("{}{}", styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]), layer(&square)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path with its style: {a:#?}");
assert_eq!(a[0].extras().graphic_style, Some(1), "linked to its style");
}
/// A brushed path keeps its drawn look when its stroke can't be read as the brush: the object's
/// own settings differ from the brush's (Options of Selected Object), its angle follows the path,
/// or the file defines no brush of its name.
/// its filter holds an entry or a variable width that isn't read, or the file defines no brush of
/// its name.
#[test]
fn a_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D);
let mut own = SIX_D_SETTINGS;
own[0] = (9.0, 2.0);
let plain = brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0);
let with = |more: &str| plain.replace("(Brush) ,\n", &format!("(Brush) ,\n{more}"));
let widths = "/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
/Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n";
let cases = [
("its own settings", def.clone(), brushed_style("Six D", 1.0, own, [2, 5, 6], 0)),
("its own modes", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 4], 0)),
("an angle on the path", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 1)),
("an entry that isn't read", def.clone(), with("1 /Real (Unread) ,\n")),
("a variable width", def.clone(), with(widths)),
("no such brush", def, brushed_style("Other", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0)),
];
for (why, def, style) in cases {
File diff suppressed because one or more lines are too long
+5
View File
@@ -221,6 +221,11 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
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-14):** Calligraphic paths whose brush filter holds an entry that isn't read or a variable width
keep their drawn look. So do paths followed by samples of the pen (`(…) Xt n XD`) whose Calligraphic brush varies
with the pen; such a path no longer also comes in beside its look, unpainted. Synthetic importer regressions;
scores unchanged.
- **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
of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art