mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 07:16:52 +00:00
Merge pull request #5771 from dotCooCoo/feat/ai-editing-data-s3
.ai save: the editing copy keeps gradients and patterns
This commit is contained in:
@@ -109,7 +109,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
|
||||
name: "editingData",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, swatches, artboards, the transparency of objects and layers, objects' opacity masks, and clipping groups and layers clipped by a path, with type, gradients, patterns, images, symbols, other clipping objects and live objects as plain filled and stroked paths (written only with every artboard, as Preserve Editing is)",
|
||||
description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards, the transparency of objects and layers, objects' opacity masks, and clipping groups and layers clipped by a path, with type, other patterns, images, symbols, other clipping objects and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "aiVersion",
|
||||
|
||||
@@ -652,3 +652,201 @@ fn transparency_clipping_and_opacity_masks_come_back_from_the_editing_copy() {
|
||||
let kinds = see_through_outline(&legacy.doc);
|
||||
assert!(kinds.iter().any(|l| l.contains(" compound ")) && kinds.iter().any(|l| l.contains("group clip=true")), "{kinds:#?}");
|
||||
}
|
||||
|
||||
/// The paths and compound paths of `doc`'s art and of its patterns' tiles with their geometric
|
||||
/// bounds (rounded) and fills and strokes, one line each, then its gradient and pattern swatches: a
|
||||
/// gradient by its kind, its swatch link (`-` when it names no gradient swatch of `doc`), its stops
|
||||
/// (place, color, opacity, midpoint, swatch link and tint) and the gradient's place at the corners and
|
||||
/// center of the paint's bounds (rounded to hundredths); a pattern by its name and placement.
|
||||
fn paint_outline(doc: &vectorcraft_doc::Document) -> Vec<String> {
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
|
||||
let link = |name: &Option<String>| match name.as_deref().and_then(|n| doc.swatch(n)) {
|
||||
Some(sw) if matches!(sw.paint, Paint::Gradient(_)) => sw.name.clone(),
|
||||
_ => "-".into(),
|
||||
};
|
||||
let drawn = |p: &Paint, b: vectorcraft_geom::Rect| match p {
|
||||
Paint::Gradient(g) => {
|
||||
let geom = g.resolve(b);
|
||||
let at: Vec<f64> = [(b.x0, b.y0), (b.x1, b.y0), (b.x0, b.y1), (b.x1, b.y1), (b.center().x, b.center().y)]
|
||||
.map(|(x, y)| (geom.param_at(g.gradient.kind, (x, y).into()) * 100.0).round() / 100.0)
|
||||
.to_vec();
|
||||
let stops: Vec<String> = g
|
||||
.gradient
|
||||
.stops
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let rgb = s.color.to_rgb().map(|v| (v * 100.0).round());
|
||||
format!("{:.3} {rgb:?} {:.2} mid {:.2} {:?} {:.2}", s.offset, s.opacity, s.midpoint, s.swatch, s.tint)
|
||||
})
|
||||
.collect();
|
||||
format!("{:?} link {} {stops:?} {at:?}", g.gradient.kind, link(&g.swatch))
|
||||
}
|
||||
Paint::Pattern { pattern, xf } => format!("pattern {pattern} {:?}", xf.as_coeffs().map(|v| (v * 1000.0).round() / 1000.0)),
|
||||
Paint::Solid { color, .. } => format!("{:?}", color.to_rgb().map(|v| (v * 100.0).round())),
|
||||
Paint::None => "none".into(),
|
||||
};
|
||||
// A compound path's paint is its own: its members' paint isn't drawn (Release gives them the
|
||||
// compound path's), so a compound path's line lists only its members' bounds.
|
||||
fn walk(nodes: &[Arc<Node>], f: &mut dyn FnMut(&Node)) {
|
||||
for n in nodes {
|
||||
f(n);
|
||||
if !matches!(n.kind, NodeKind::Compound { .. }) {
|
||||
walk(n.children().map(Vec::as_slice).unwrap_or_default(), f);
|
||||
}
|
||||
}
|
||||
}
|
||||
let bounds = |n: &Node| n.geometric_bounds().map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| v.round() as i64));
|
||||
let lines = |nodes: &[Arc<Node>]| {
|
||||
let mut lines = vec![];
|
||||
walk(nodes, &mut |n| {
|
||||
if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
return;
|
||||
}
|
||||
let Some(b) = n.geometric_bounds() else { return };
|
||||
let kind = match n.children() {
|
||||
Some(members) => format!("compound of {:?}", members.iter().map(|m| bounds(m)).collect::<Vec<_>>()),
|
||||
None => "path".into(),
|
||||
};
|
||||
let mut line = format!("{kind} {:?}", [b.x0, b.y0, b.x1, b.y1].map(|v| v.round() as i64));
|
||||
for it in n.appearance.items.iter().filter(|i| !i.paint().is_none()) {
|
||||
line += &match it {
|
||||
AppearanceItem::Fill(f) => format!(" fill {}", drawn(&f.paint, b)),
|
||||
AppearanceItem::Stroke(st) => format!(" stroke {} {}", st.width, drawn(&st.paint, st.paint_bounds(b))),
|
||||
};
|
||||
}
|
||||
lines.push(line);
|
||||
});
|
||||
lines
|
||||
};
|
||||
let mut out = lines(&doc.layers);
|
||||
for p in doc.patterns.iter() {
|
||||
out.push(format!("pattern {} tile {:?}", p.name, [p.tile.x0, p.tile.y0, p.tile.x1, p.tile.y1]));
|
||||
out.extend(lines(&p.art));
|
||||
}
|
||||
for sw in doc.swatches_iter() {
|
||||
match &sw.paint {
|
||||
// A gradient swatch reads back linked to itself.
|
||||
Paint::Gradient(g) => {
|
||||
let own = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(g.gradient.clone())));
|
||||
out.push(format!("swatch {} {}", sw.name, drawn(&own, vectorcraft_geom::Rect::ZERO)));
|
||||
}
|
||||
Paint::Pattern { pattern, .. } => out.push(format!("swatch {} pattern {pattern}", sw.name)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A document of two artboards (the art on the first): a rectangle filled with a linear gradient of
|
||||
/// three stops placed across part of it, an ellipse filled with an elliptical radial gradient whose
|
||||
/// focal point is off its center, a rectangle stroked with a gradient, rectangles filled and
|
||||
/// stroked with the pattern `Dots`, one of them turned, an even-odd compound path filled with a
|
||||
/// gradient of a gray, a CMYK color and the spot color `Ink` at 70%, and the gradient swatch `Dawn`.
|
||||
fn gradient_session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 2})).unwrap();
|
||||
let id = |v: serde_json::Value| v["id"].as_u64().unwrap();
|
||||
let rect = |s: &mut Session, x: f64, y: f64, w: f64, h: f64| {
|
||||
id(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
};
|
||||
let three = json!({"kind": "linear", "stops": [
|
||||
{"offset": 0, "color": "#ff0000", "midpoint": 0.3},
|
||||
{"offset": 0.4, "color": "#00ff00", "opacity": 0.5},
|
||||
{"offset": 1, "color": "#0000ff"}
|
||||
], "start": [20, 40], "end": [60, 17]});
|
||||
let a = rect(&mut s, 10.0, 10.0, 80.0, 40.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [a], "gradient": three})).unwrap();
|
||||
let e = id(s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 80, "height": 40})).unwrap());
|
||||
let glow = json!({"kind": "radial", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#1a33cc"}],
|
||||
"start": [140, 30], "end": [180, 30], "aspect": 50, "focal": [150, 25]});
|
||||
s.execute("paint.setFill", &json!({"ids": [e], "gradient": glow})).unwrap();
|
||||
let c = rect(&mut s, 190.0, 10.0, 90.0, 40.0);
|
||||
let two = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#000000"}]});
|
||||
s.execute("paint.setStroke", &json!({"ids": [c], "gradient": two, "width": 6})).unwrap();
|
||||
let tile = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [tile], "color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [tile], "none": true})).unwrap();
|
||||
s.execute("object.pattern.make", &json!({"ids": [tile], "name": "Dots", "edit": false})).unwrap();
|
||||
let p = rect(&mut s, 10.0, 70.0, 80.0, 40.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [p], "swatch": "Dots"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [p], "swatch": "Dots", "width": 4})).unwrap();
|
||||
let turned = rect(&mut s, 100.0, 70.0, 80.0, 40.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [turned], "swatch": "Dots"})).unwrap();
|
||||
// Turned 30° and scaled to half about (140, 90).
|
||||
let (c, k) = (30f64.to_radians().cos() * 0.5, 30f64.to_radians().sin() * 0.5);
|
||||
let m = [c, k, -k, c, 140.0 - 140.0 * c + 90.0 * k, 90.0 - 140.0 * k - 90.0 * c];
|
||||
s.execute("pattern.transform", &json!({"ids": [turned], "matrix": m})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": "#ff9900", "spot": true})).unwrap();
|
||||
let outer = rect(&mut s, 190.0, 70.0, 90.0, 40.0);
|
||||
let hole = rect(&mut s, 210.0, 80.0, 40.0, 20.0);
|
||||
let ring = id(s.execute("object.compoundPath.make", &json!({"ids": [outer, hole]})).unwrap());
|
||||
let mixed = json!({"kind": "linear", "stops": [
|
||||
{"offset": 0, "color": {"gray": 0.8}},
|
||||
{"offset": 0.5, "color": {"c": 1, "m": 0, "y": 0, "k": 0}, "midpoint": 0.7},
|
||||
{"offset": 1, "swatch": "Ink", "tint": 70}
|
||||
]});
|
||||
s.execute("paint.setFill", &json!({"ids": [ring], "gradient": mixed})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [ring], "none": true})).unwrap();
|
||||
let dawn = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ffcc00"}, {"offset": 1, "color": "#cc3300"}]});
|
||||
s.execute("swatch.new", &json!({"name": "Dawn", "gradient": dawn})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Gradients and patterns come back from the editing copy for other apps alone (the native
|
||||
/// document left out), with every save option that writes the copy; where the file has PDF pages,
|
||||
/// the reader takes the copy only when it draws as its page does. The version 7 `.ai` keeps each
|
||||
/// gradient.
|
||||
#[test]
|
||||
fn gradients_and_patterns_come_back_from_the_editing_copy() {
|
||||
let mut s = gradient_session();
|
||||
let before = (*s.doc().unwrap().doc).clone();
|
||||
let want = paint_outline(&before);
|
||||
assert!(want.iter().filter(|l| l.starts_with("path") && l.contains("pattern Dots")).count() == 2, "{want:#?}");
|
||||
assert!(want.iter().any(|l| l.starts_with("compound") && l.contains("Ink") && l.contains(") 0.70")), "{want:#?}");
|
||||
assert!(want.iter().any(|l| l.contains("mid 0.30")) && want.iter().any(|l| l.starts_with("swatch Dawn")), "{want:#?}");
|
||||
for opts in [
|
||||
json!({"format": "ai"}),
|
||||
json!({"format": "ai", "compress": false}),
|
||||
json!({"format": "ai", "embedProfiles": false}),
|
||||
json!({"format": "ai", "includeLinked": true}),
|
||||
json!({"format": "ai", "range": "all"}),
|
||||
json!({"format": "ai", "pdfCompatible": false}),
|
||||
] {
|
||||
let r = s.execute("document.export", &opts).unwrap();
|
||||
assert!(!r["warnings"].to_string().contains("gradient") && !r["warnings"].to_string().contains("pattern"), "{r}");
|
||||
let copy = editing_copy(&b64(&r));
|
||||
assert_eq!(paint_outline(©), want, "{opts}");
|
||||
}
|
||||
let path = tmp("gradients.ai");
|
||||
let r = s.execute("document.save", &json!({"path": path, "separateArtboards": true})).unwrap();
|
||||
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(files.len(), 3, "{r}");
|
||||
for f in files.iter().filter(|f| !f.ends_with("-2.ai")) {
|
||||
assert_eq!(paint_outline(&editing_copy(&std::fs::read(f).unwrap())), want, "{f}");
|
||||
}
|
||||
let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": 7})).unwrap();
|
||||
let legacy = load::load_with("art.ai", &b64(&r), &LoadOptions::default()).unwrap();
|
||||
// Each gradient the art paints with, by its kind and its stops' places and colors to 5% (version
|
||||
// 7 writes a gradient stroke as its filled outline, so the objects differ, and a spot color's
|
||||
// tint through its CMYK equivalent, 2% off here).
|
||||
let gradients = |doc: &vectorcraft_doc::Document| {
|
||||
let mut all = std::collections::BTreeSet::new();
|
||||
doc.walk(|node| {
|
||||
for it in node.appearance.items.iter() {
|
||||
if let vectorcraft_color::Paint::Gradient(g) = it.paint() {
|
||||
let stops: Vec<String> = g
|
||||
.gradient
|
||||
.stops
|
||||
.iter()
|
||||
.map(|s| format!("{:.3} {:?}", s.offset, s.color.to_rgb().map(|v| (v * 20.0).round() * 5.0)))
|
||||
.collect();
|
||||
all.insert(format!("{:?} {stops:?}", g.gradient.kind));
|
||||
}
|
||||
}
|
||||
});
|
||||
all
|
||||
};
|
||||
assert_eq!(gradients(&legacy.doc), gradients(&before));
|
||||
}
|
||||
|
||||
@@ -3307,6 +3307,24 @@ proptest! {
|
||||
survive("hostile .ai line widths", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
|
||||
/// A `.ai` whose pattern definition (a marked tile box, a gradient placed by `Bg`'s matrix) and
|
||||
/// pattern fill are damaged or hostile.
|
||||
#[test]
|
||||
fn ai_pattern_definitions_never_panic(
|
||||
cut in 0usize..2_000,
|
||||
edits in prop::collection::vec((0usize..2_000, prop::sample::select(vec!['0', '1', '-', '.', ' ', '\n', '(', ')', '[', ']', '%', '_', 'n', 'E', 'p', 'B'])), 0..12),
|
||||
) {
|
||||
use vectorcraft_testkit::ai;
|
||||
let gradient = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let pattern = "%AI3_BeginPattern: (P)\n(P) 0 -10 10 0\n%_0 A\n%_0 Xw\n%_0 XR\n%_0 0 m\n%_10 0 L\n%_10 -10 L\n%_0 -10 L\n%_0 0 L\n%_n\n\
|
||||
%_/ArtDictionary :\n%_1 /Bool (AIPattern_Editor_Backing_Tile_Rect) ,\n%_;\n%_\n%_2 -2 m\n%_8 -2 L\n%_8 -8 L\n%_2 -8 L\n%_Bb\n\
|
||||
%_0.1 0.2 45 0.3 Bh\n%_1 (G) 0.1 0.2 0 1.5 3 0 0 3 0 0 1 Bg\n%_3 0 0 3 -8000 8000 Bm\n%_f\n%_0 BB\nE\n%AI3_EndPattern\n";
|
||||
let art = format!("(P) 0 0 1 1 0 0 0 0 0 [0.5 0.2 -0.2 0.5 3 -4] p\n{}f\n", ai::rect(20.0, 20.0, 80.0, 80.0));
|
||||
let data = mutate_text(&ai::editing_data(100.0, 100.0, &format!("{gradient}{pattern}{}", ai::layer("L", &art))), cut, &edits);
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&data)].concat();
|
||||
survive("hostile .ai patterns", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
|
||||
/// A `.ai` whose variable library is damaged or hostile: the library is counted for the warning
|
||||
/// whether the layers are read or not (an inserted `Z` can make an operator the reader doesn't
|
||||
/// handle, and then the layers aren't read).
|
||||
|
||||
@@ -18,11 +18,16 @@
|
||||
//! `%AI5_ArtFlags`, `%AI5_TargetResolution`, `%AI5_NumLayers`, `%AI5_OpenViewLayers` (a digit for
|
||||
//! each layer, 7 shown and 6 hidden), `%AI7_GridSettings` (§2.1.1), and the version comment
|
||||
//! `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]).
|
||||
//! - Setup: `0 Bn` (no gradient definitions, §3), the color swatches (`%AI5_BeginPalette`,
|
||||
//! `0 0 Pb`, the Registration swatch, each swatch's color operator and `(name) Pc`, a color group as `0 (name) count Pg`
|
||||
//! before its swatches, `PB`), then the artboards as the `ArtboardArray` of the `/Document`
|
||||
//! dictionary's `/Recorded` part and the canvas size as its `AIDocumentCanvasSize`, on `%_`
|
||||
//! lines.
|
||||
//! - Setup: the gradients the art uses as `n Bn` and a definition of each (§3.5: `%AI5_BeginGradient:
|
||||
//! (name)`, `(name) type stops Bd`, `[`, each stop on a `%_` line as `color style opacity 6 midpoint
|
||||
//! rampPoint Bs`, `BD`, `%AI5_EndGradient`), those only the swatches use in a non-printing block
|
||||
//! (§14: `%AI5_Begin_NonPrinting`, `Np`, `%AI5_End_NonPrinting--`); the patterns the same way
|
||||
//! (§3.4: `%AI3_BeginPattern: (name)`, `(name) llx lly urx ury`, the tile's art on `%_` lines, `E`,
|
||||
//! `%AI3_EndPattern`); the swatches (`%AI5_BeginPalette`, `0 0 Pb`, the Registration swatch, each
|
||||
//! swatch's color operator, gradient instance or pattern operator and `(name) Pc`, a color group as
|
||||
//! `0 (name) count Pg` before its swatches, `PB`), then the artboards as the `ArtboardArray` of the
|
||||
//! `/Document` dictionary's `/Recorded` part and the canvas size as its `AIDocumentCanvasSize`, on
|
||||
//! `%_` lines.
|
||||
//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed 0 1 colorIndex r g b 0
|
||||
//! dimPercent clipping Lb` (14 operands; a color that isn't one of the preset layer colors has the
|
||||
//! index -1; `clipping` is 1 for a clipping layer), `(name) Ln`, the layer's name as an XML id in an
|
||||
@@ -30,7 +35,19 @@
|
||||
//! - Colors: `g` gray, `k` CMYK, `c m y k r g b Xa` RGB (Lab is written as RGB); a spot color
|
||||
//! `c m y k (name) tint x` (CMYK) or `c m y k r g b|L a b (name) tint 1|2 Xx` (RGB or Lab); a
|
||||
//! global process color `… (name) tint model Xk`; Registration `1 1 1 1 ([Registration]) tint
|
||||
//! Xs`. Upper case paints the stroke.
|
||||
//! Xs`; a pattern `(name) 0 0 1 1 0 0 0 0 0 [matrix] p` (`P`), the matrix taking the pattern's
|
||||
//! space (its tile's lower left corner the origin) to art space. Upper case paints the stroke.
|
||||
//! - Gradients (§3.5.3): around the painting operator of a path (of a compound path's last path),
|
||||
//! `Bb` (`1 Bb` for the stroke), a radial gradient's focal point `x y angle fraction Bh`, then
|
||||
//! `flag (name) x y angle length a b c d tx ty 1 Bg`, which places the gradient on the path: a
|
||||
//! linear one with the identity matrix, `x` the start and `length` the length as fractions of the
|
||||
//! path's extent along `angle`; a radial one with the matrix taking the unit circle onto its
|
||||
//! extent ellipse, `x` and `y` its center as fractions of the path's bounds there and `length` its
|
||||
//! radius as a multiple of the one the bounds give (`writer::paints::placement`). The imaging data
|
||||
//! follows (a linear gradient's `a b c d tx ty Xm`, its caps `Bc` and a `Bm` for each pair of
|
||||
//! stops; a radial one's `Bm`), then the operator and `0 BB` (`2 BB` or `1 BB` when the path is
|
||||
//! stroked too; a fill's instance ends `2 BB` before a stroke's). In a pattern's tile the matrices
|
||||
//! are in the canvas's space (y down, from art space's origin there).
|
||||
//! - State before each object: locked `A`, hidden `Xw` (`1` or `0`). An object's name follows it as
|
||||
//! an `/ArtDictionary` whose `AI10_ArtUID` is the name as an XML id. Guides are the path
|
||||
//! followed by `(N) *` (`(n) *` when closed), without the `(N) *` that §6 also puts before it.
|
||||
@@ -50,14 +67,19 @@
|
||||
//! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the
|
||||
//! artboards together are centered; each artboard's `RulerOrigin` is its top-left corner there, and
|
||||
//! `%AI3_TemplateBox` holds the canvas's center in art space. What the form above can't hold is written as plain filled and stroked paths, each such
|
||||
//! loss a warning: type as its outlines and its inline graphics' art, gradients as their average color, pattern fills as their
|
||||
//! tiles cut to the shape, clipping groups and clipping layers whose clipping object isn't a path,
|
||||
//! loss a warning: type as its outlines and its inline graphics' art, freeform gradients, gradients
|
||||
//! along or across a stroke and gradients without stops, with more than 256 stops or without extent
|
||||
//! as their average color
|
||||
//! (gradients that dither or mix perceptually are written as classic ones, and gradient stops linked
|
||||
//! to global process colors as their colors), pattern fills whose tiles aren't a grid, whose art reaches past
|
||||
//! the tile or is painted with patterns as their tiles cut to the shape, clipping groups and clipping layers whose clipping object isn't a path,
|
||||
//! and the clipping groups inside them, as their art cut to that object (its fill cut too, its
|
||||
//! stroke not), images as their outlines filled with
|
||||
//! their average color, symbols and live objects as their art (symbol art past
|
||||
//! [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects in all left out), strokes a plain line can't draw
|
||||
//! as their filled outlines, several fills or strokes as a group of a path for each, gradient and
|
||||
//! pattern swatches, the opacity and blending modes of single fills and strokes, opacity masks on
|
||||
//! as their filled outlines, several fills or strokes as a group of a path for each, freeform
|
||||
//! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as
|
||||
//! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on
|
||||
//! layers and opacity masks nested too deep, and live effects other than geometry effects left out.
|
||||
//!
|
||||
//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with
|
||||
@@ -160,10 +182,15 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
|
||||
// The document is y down; art space is ruler space: y up, its origin the first artboard's
|
||||
// top-left corner.
|
||||
let to_art = Affine::new([1.0, 0.0, 0.0, -1.0, -board.x0, board.y0]);
|
||||
let mut w = Writer::new(doc, to_art);
|
||||
let origin = ruler_origin(doc, to_art);
|
||||
let mut w = Writer::new(doc, to_art, origin);
|
||||
w.reserve_swatch_gradients();
|
||||
for layer in &doc.layers {
|
||||
w.layer(layer);
|
||||
}
|
||||
// The palette first: its gradient and pattern swatches add definitions.
|
||||
let mut palette = String::new();
|
||||
w.palette(&mut palette);
|
||||
let first = to_art.transform_rect_bbox(board);
|
||||
let art = doc
|
||||
.layers
|
||||
@@ -174,12 +201,11 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
|
||||
.filter(|b| b.is_finite())
|
||||
.reduce(|a, b| a.union(b))
|
||||
.unwrap_or(first);
|
||||
let origin = ruler_origin(doc, to_art);
|
||||
let header = header(doc, o, &w, art, first, origin, scale);
|
||||
let mut s = header.clone();
|
||||
// No gradient definitions: gradients are written as their average color.
|
||||
s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n0 Bn\n");
|
||||
w.palette(&mut s);
|
||||
s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n");
|
||||
w.definitions(&mut s);
|
||||
s.push_str(&palette);
|
||||
artboards(&mut s, doc, to_art, origin);
|
||||
s.push_str("%%EndSetup\n");
|
||||
s.push_str(&w.body);
|
||||
|
||||
@@ -602,8 +602,8 @@ fn hidden_layers_open_hidden() {
|
||||
assert!(text.contains("%AI5_NumLayers: 3\n%AI5_OpenViewLayers: 776\n"), "{text}");
|
||||
}
|
||||
|
||||
/// A 200 × 100 document of what the data holds as plain paths: a gradient fill, a compound path,
|
||||
/// an image, type and a symbol instance.
|
||||
/// A 200 × 100 document of a gradient fill and a compound path, and what the data holds as plain
|
||||
/// paths: an image, type and a symbol instance.
|
||||
fn unsupported() -> Document {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
@@ -665,7 +665,7 @@ fn paths(nodes: &[Arc<Node>], out: &mut Vec<Node>) {
|
||||
fn art_the_slice_does_not_cover_is_written_as_plain_paths() {
|
||||
let doc = unsupported();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
for w in [writer::GRADIENTS, writer::IMAGES, writer::TYPE, writer::LIVE] {
|
||||
for w in [writer::IMAGES, writer::TYPE, writer::LIVE] {
|
||||
assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings);
|
||||
}
|
||||
let back = read_back(&doc);
|
||||
@@ -683,8 +683,10 @@ fn art_the_slice_does_not_cover_is_written_as_plain_paths() {
|
||||
let mut all = vec![];
|
||||
paths(layer, &mut all);
|
||||
assert!(all.iter().all(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })));
|
||||
// The gradient as its average color.
|
||||
assert_eq!(paint(back.layers[0].children().unwrap()[0].appearance.items[0].paint()), paint(&solid(Color::rgb(0.5, 0.5, 0.5))));
|
||||
// The gradient as a gradient, drawing as it did.
|
||||
let bounds = Rect::new(10.0, 10.0, 30.0, 30.0);
|
||||
let original = doc.layers[0].children().unwrap()[0].appearance.items[0].paint();
|
||||
assert_eq!(drawn(&back, layer[0].appearance.items[0].paint(), bounds), drawn(&doc, original, bounds));
|
||||
// The image's outline filled with its pixels' average.
|
||||
assert_eq!(paint(layer[2].appearance.items[0].paint()), paint(&solid(Color::rgb(0.5, 0.0, 0.5))));
|
||||
assert_eq!(layer[2].visual_bounds().map(|b| (r3(b.x0), r3(b.y0), r3(b.x1), r3(b.y1))), Some((r3(100.0), r3(10.0), r3(120.0), r3(20.0))));
|
||||
@@ -937,19 +939,24 @@ fn a_group_fill_in_a_pattern_tile_is_written() {
|
||||
doc.insert(Some(layer), usize::MAX, filled).unwrap();
|
||||
let back = read_back(&doc);
|
||||
let mut all = vec![];
|
||||
paths(back.layers[0].children().unwrap(), &mut all);
|
||||
paths(&back.pattern("Dots").unwrap().art, &mut all);
|
||||
let red = paint(&solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert!(all.iter().any(|p| p.appearance.items.iter().any(|i| paint(i.paint()) == red)), "the tiles keep their group's fill");
|
||||
assert!(all.iter().any(|p| p.appearance.items.iter().any(|i| paint(i.paint()) == red)), "the tile keeps its group's fill");
|
||||
}
|
||||
|
||||
/// A pattern fill that would place more tiles than a fill places is written as the shape filled
|
||||
/// gray, as a pattern that isn't found is, instead of as nothing.
|
||||
/// A pattern fill written as its tiles (its tiles aren't a grid) that would place more tiles than
|
||||
/// a fill places is written as the shape filled gray, as a pattern that isn't found is, instead of
|
||||
/// as nothing.
|
||||
#[test]
|
||||
fn a_pattern_fill_of_too_many_tiles_is_written_gray() {
|
||||
let mut doc = Document::new(1200.0, 1200.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let dot = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 0.5, 0.5)), Appearance::basic(solid(Color::BLACK), Paint::None, 0.0));
|
||||
let tiny = vectorcraft_doc::PatternDef { tile: Rect::new(0.0, 0.0, 1.0, 1.0), ..vectorcraft_doc::PatternDef::new("Tiny", vec![Arc::new(dot)]) };
|
||||
let tiny = vectorcraft_doc::PatternDef {
|
||||
tile: Rect::new(0.0, 0.0, 1.0, 1.0),
|
||||
tile_type: vectorcraft_doc::TileType::BrickByRow { offset: 0.5 },
|
||||
..vectorcraft_doc::PatternDef::new("Tiny", vec![Arc::new(dot)])
|
||||
};
|
||||
doc.patterns.push(tiny);
|
||||
let filled = Node::path(
|
||||
doc.alloc_id(),
|
||||
@@ -1386,11 +1393,9 @@ fn a_clipping_paths_other_paint_keeps_its_opacity() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut clip = clipping(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)));
|
||||
let g = Gradient::new(
|
||||
GradientKind::Linear,
|
||||
vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))],
|
||||
);
|
||||
clip.appearance = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(g))), Paint::None, 0.0).into();
|
||||
// Two fills: a path paints one.
|
||||
let fills = [Color::rgb(0.5, 0.5, 0.5), Color::rgb(1.0, 0.0, 0.0)].map(|c| AppearanceItem::Fill(FillLayer::new(solid(c))));
|
||||
clip.appearance = Appearance { items: fills.to_vec(), ..Appearance::default() }.into();
|
||||
clip.opacity = 0.5;
|
||||
let inside = square(&mut doc, Rect::new(0.0, 0.0, 40.0, 20.0), Color::rgb(0.0, 0.0, 1.0));
|
||||
let g = group(&mut doc, vec![clip, inside], true);
|
||||
@@ -1409,21 +1414,42 @@ fn a_clipping_paths_other_paint_keeps_its_opacity() {
|
||||
let out = export_ai_editing(&masked, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::CLIP_PAINT_MASKS), "{:?}", out.warnings);
|
||||
// Two fills at the clipping path's 50% are a group at 50% of the two at 100%, as the canvas
|
||||
// draws them together.
|
||||
let mut two = doc.clone();
|
||||
if let Some(c) = Arc::make_mut(&mut two.layers[0]).children_mut()
|
||||
&& let Some(NodeKind::Group { children, .. }) = c.first_mut().map(|g| &mut Arc::make_mut(g).kind)
|
||||
{
|
||||
let clip = Arc::make_mut(&mut children[0]);
|
||||
let mut items = clip.appearance.items.clone();
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(solid(Color::rgb(1.0, 0.0, 0.0)))));
|
||||
clip.appearance = Appearance { items, ..Appearance::default() }.into();
|
||||
}
|
||||
let back = read_back(&two);
|
||||
// draws them together, under the art.
|
||||
let back = read_back(&doc);
|
||||
let NodeKind::Group { children, clip: true } = &back.layers[0].children().unwrap()[0].kind else { panic!("a clipping group") };
|
||||
let fills = &children[1];
|
||||
assert!(matches!(fills.kind, NodeKind::Group { clip: false, .. }) && fills.opacity == 0.5, "{fills:?}");
|
||||
assert_eq!(fills.children().unwrap().iter().map(|c| c.opacity).collect::<Vec<_>>(), [1.0, 1.0]);
|
||||
let colors: Vec<String> = fills.children().unwrap().iter().map(|c| paint(c.appearance.items[0].paint())).collect();
|
||||
assert_eq!(colors, [paint(&solid(Color::rgb(0.5, 0.5, 0.5))), paint(&solid(Color::rgb(1.0, 0.0, 0.0)))]);
|
||||
assert!(matches!(children[2].kind, NodeKind::Path { .. }) && children.len() == 3, "the art over the fills");
|
||||
// One fill a path can't paint (a brick pattern, written as its tiles) is written under the art
|
||||
// at the clipping path's opacity.
|
||||
let mut brick = doc.clone();
|
||||
let dot = square(&mut brick, Rect::new(0.0, 0.0, 2.0, 2.0), Color::rgb(1.0, 0.0, 0.0));
|
||||
brick.patterns.push(vectorcraft_doc::PatternDef {
|
||||
tile: Rect::new(0.0, 0.0, 4.0, 4.0),
|
||||
tile_type: vectorcraft_doc::TileType::BrickByRow { offset: 0.5 },
|
||||
..vectorcraft_doc::PatternDef::new("Brick", vec![Arc::new(dot)])
|
||||
});
|
||||
if let Some(c) = Arc::make_mut(&mut brick.layers[0]).children_mut()
|
||||
&& let Some(NodeKind::Group { children, .. }) = c.first_mut().map(|g| &mut Arc::make_mut(g).kind)
|
||||
{
|
||||
let fill = Paint::Pattern { pattern: "Brick".into(), xf: Affine::IDENTITY };
|
||||
Arc::make_mut(&mut children[0]).appearance = Appearance::basic(fill, Paint::None, 0.0).into();
|
||||
}
|
||||
let out = export_ai_editing(&brick, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PATTERNS) && out.warnings.iter().any(|w| w == writer::CLIP_PAINT), "{:?}", out.warnings);
|
||||
let back = read_back(&brick);
|
||||
let NodeKind::Group { children, clip: true } = &back.layers[0].children().unwrap()[0].kind else { panic!("a clipping group") };
|
||||
assert_eq!(children.len(), 3, "the clipping path, its fill, the art");
|
||||
assert_eq!(children[1].opacity, 0.5);
|
||||
let mut tiles = vec![];
|
||||
paths(std::slice::from_ref(&children[1]), &mut tiles);
|
||||
let painted: Vec<&Node> = tiles.iter().filter(|t| !t.appearance.items.is_empty()).collect();
|
||||
let red = paint(&solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert!(!painted.is_empty() && painted.iter().all(|t| t.opacity == 1.0 && paint(t.appearance.items[0].paint()) == red), "{painted:?}");
|
||||
assert_eq!(paint(children[2].appearance.items[0].paint()), paint(&solid(Color::rgb(0.0, 0.0, 1.0))), "the art over the fill");
|
||||
// A locked clipping path's fill is locked too, the art it clips not.
|
||||
let mut locked = doc.clone();
|
||||
if let Some(c) = Arc::make_mut(&mut locked.layers[0]).children_mut()
|
||||
@@ -1434,12 +1460,6 @@ fn a_clipping_paths_other_paint_keeps_its_opacity() {
|
||||
let back = read_back(&locked);
|
||||
let NodeKind::Group { children, clip: true } = &back.layers[0].children().unwrap()[0].kind else { panic!("a clipping group") };
|
||||
assert_eq!(children.iter().map(|c| c.locked).collect::<Vec<_>>(), [true, true, false]);
|
||||
let back = read_back(&doc);
|
||||
let NodeKind::Group { children, clip: true } = &back.layers[0].children().unwrap()[0].kind else { panic!("a clipping group") };
|
||||
// The clipping path, its fill as a path under the art, the art.
|
||||
let fill = &children[1];
|
||||
assert!(matches!(fill.kind, NodeKind::Path { clipping: false, .. }));
|
||||
assert_eq!((fill.opacity, paint(fill.appearance.items[0].paint())), (0.5, paint(&solid(Color::rgb(0.5, 0.5, 0.5)))));
|
||||
}
|
||||
|
||||
/// A clipping path's stroke that a plain line can't draw goes over the clipped art, its fill under it.
|
||||
@@ -1448,14 +1468,10 @@ fn a_clipping_paths_other_stroke_goes_over_the_art() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut clip = clipping(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)));
|
||||
let g = Gradient::new(
|
||||
GradientKind::Linear,
|
||||
vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))],
|
||||
);
|
||||
let mut stroke = StrokeLayer::new(solid(Color::rgb(1.0, 0.0, 0.0)), 4.0);
|
||||
stroke.dash = Some(Dash { pattern: vec![2.0, 2.0], offset: 0.0, align_corners: true });
|
||||
clip.appearance = Appearance {
|
||||
items: vec![AppearanceItem::Fill(FillLayer::new(Paint::Gradient(Box::new(GradientPaint::new(g))))), AppearanceItem::Stroke(stroke)],
|
||||
items: vec![AppearanceItem::Fill(FillLayer::new(solid(Color::rgb(0.5, 0.5, 0.5)))), AppearanceItem::Stroke(stroke)],
|
||||
..Appearance::default()
|
||||
}
|
||||
.into();
|
||||
@@ -1465,7 +1481,7 @@ fn a_clipping_paths_other_stroke_goes_over_the_art() {
|
||||
let back = read_back(&doc);
|
||||
let NodeKind::Group { children, clip: true } = &back.layers[0].children().unwrap()[0].kind else { panic!("a clipping group") };
|
||||
let colors: Vec<String> = children.iter().skip(1).map(|c| paint(c.appearance.items.first().map_or(&Paint::None, |i| i.paint()))).collect();
|
||||
// The fill (its average) under the blue art, the red stroke's dots over it.
|
||||
// The fill under the blue art, the red stroke's dots over it.
|
||||
assert_eq!(colors.first(), Some(&paint(&solid(Color::rgb(0.5, 0.5, 0.5)))), "{colors:?}");
|
||||
assert_eq!(colors.get(1), Some(&paint(&solid(Color::rgb(0.0, 0.0, 1.0)))), "{colors:?}");
|
||||
assert!(colors.len() > 2 && colors.iter().skip(2).all(|c| *c == paint(&solid(Color::rgb(1.0, 0.0, 0.0)))), "{colors:?}");
|
||||
@@ -1692,3 +1708,773 @@ fn transparency_of_single_fills_and_layer_masks_is_reported_left_out() {
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|x| x == writer::ITEM_TRANSPARENCY), "{:?}", out.warnings);
|
||||
}
|
||||
|
||||
// ---------- gradients and patterns ----------
|
||||
|
||||
const INK: Color = Color::Cmyk { c: 0.0, m: 0.6, y: 1.0, k: 0.0 };
|
||||
|
||||
/// A gradient of `kind` through `stops` (offset, color, opacity, midpoint).
|
||||
fn ramp(kind: GradientKind, stops: &[(f32, Color, f32, f32)]) -> Gradient {
|
||||
let stops =
|
||||
stops.iter().map(|&(offset, color, opacity, midpoint)| GradientStop { opacity, midpoint, ..GradientStop::new(offset, color) }).collect();
|
||||
Gradient::new(kind, stops)
|
||||
}
|
||||
|
||||
/// The three-stop linear gradient of the `Sunset` swatch: its middle stop half opaque, its first
|
||||
/// midpoint at 30%.
|
||||
fn sunset() -> Gradient {
|
||||
ramp(
|
||||
GradientKind::Linear,
|
||||
&[(0.0, Color::rgb(1.0, 0.0, 0.0), 1.0, 0.3), (0.4, Color::rgb(0.0, 1.0, 0.0), 0.5, 0.5), (1.0, Color::rgb(0.0, 0.0, 1.0), 1.0, 0.5)],
|
||||
)
|
||||
}
|
||||
|
||||
fn gradient_paint(g: Gradient, geom: Option<vectorcraft_color::gradient::GradientGeom>, swatch: Option<&str>) -> Paint {
|
||||
Paint::Gradient(Box::new(GradientPaint { geom, swatch: swatch.map(Into::into), ..GradientPaint::new(g) }))
|
||||
}
|
||||
|
||||
fn painted(doc: &mut Document, path: PathData, items: Vec<AppearanceItem>) -> Node {
|
||||
Node::path(doc.alloc_id(), path, Appearance { items, ..Appearance::default() })
|
||||
}
|
||||
|
||||
/// A 300 × 200 document of gradients and patterns. Swatches: the linear gradient `Sunset`, the radial
|
||||
/// gradient `Glow` that nothing uses, the spot color `Ink`, the pattern `Dots` (a 10-point grid tile of
|
||||
/// a red square and a circle filled with a gradient) and `Checks`, which nothing uses. Art, bottom
|
||||
/// first: a rectangle filled with `Sunset`
|
||||
/// placed at 30° over part of it and stroked black; an ellipse filled with a radial gradient half as
|
||||
/// high as wide whose focal point is off its center; a rectangle filled yellow and stroked with a
|
||||
/// gradient; one filled and stroked with gradients; an even-odd compound path filled with a
|
||||
/// gradient; an open path filled with a gradient; a rectangle filled with a gradient of a gray, a
|
||||
/// CMYK color and `Ink` at 70%; a rectangle filled with `Dots` turned, scaled and moved; and a
|
||||
/// rectangle stroked 8 points with `Dots`.
|
||||
fn gradients_and_patterns() -> Document {
|
||||
use vectorcraft_color::gradient::GradientGeom;
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
let glow = ramp(GradientKind::Radial, &[(0.0, Color::rgb(1.0, 1.0, 1.0), 1.0, 0.5), (1.0, Color::rgb(0.1, 0.2, 0.8), 1.0, 0.5)]);
|
||||
let two = ramp(GradientKind::Linear, &[(0.0, Color::rgb(1.0, 1.0, 0.0), 1.0, 0.5), (1.0, Color::rgb(0.0, 0.0, 0.0), 1.0, 0.5)]);
|
||||
// The pattern art.
|
||||
let red = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::rgb(1.0, 0.0, 0.0));
|
||||
let ball = painted(
|
||||
&mut doc,
|
||||
shapes::ellipse(Rect::new(5.0, 5.0, 9.0, 9.0)),
|
||||
vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(two.clone(), None, None)))],
|
||||
);
|
||||
let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![Arc::new(red), Arc::new(ball)]);
|
||||
dots.tile = Rect::new(0.0, 0.0, 10.0, 10.0);
|
||||
let check = square(&mut doc, Rect::new(0.0, 0.0, 5.0, 5.0), Color::BLACK);
|
||||
let mut checks = vectorcraft_doc::PatternDef::new("Checks", vec![Arc::new(check)]);
|
||||
checks.tile = Rect::new(0.0, 0.0, 10.0, 10.0);
|
||||
doc.patterns.extend([dots, checks]);
|
||||
let swatch = |name: &str, paint: Paint, spot: bool| Swatch { name: name.into(), paint, global: spot, spot };
|
||||
let mut swatches: Vec<Swatch> = doc.swatches.iter().filter(|s| matches!(s.paint, Paint::None)).cloned().collect();
|
||||
swatches.extend([
|
||||
swatch("Sunset", gradient_paint(sunset(), None, None), false),
|
||||
swatch("Glow", gradient_paint(glow.clone(), None, None), false),
|
||||
swatch("Ink", solid(INK), true),
|
||||
swatch("Dots", Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, false),
|
||||
swatch("Checks", Paint::Pattern { pattern: "Checks".into(), xf: Affine::IDENTITY }, false),
|
||||
]);
|
||||
doc.swatches = swatches.into();
|
||||
doc.swatch_groups = Default::default();
|
||||
let layer = doc.layers[0].id;
|
||||
let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p));
|
||||
let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w));
|
||||
let placed = GradientGeom { start: (20.0, 40.0).into(), end: (60.0, 17.0).into(), aspect: 1.0, focal: None };
|
||||
let mut oval = GradientGeom { start: (140.0, 30.0).into(), end: (180.0, 30.0).into(), aspect: 0.5, focal: None };
|
||||
oval.set_focal(Some((150.0, 25.0).into()));
|
||||
let mut nodes = vec![
|
||||
painted(
|
||||
&mut doc,
|
||||
shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 50.0)),
|
||||
vec![fill(gradient_paint(sunset(), Some(placed), Some("Sunset"))), stroke(solid(Color::BLACK), 2.0)],
|
||||
),
|
||||
painted(&mut doc, shapes::ellipse(Rect::new(100.0, 10.0, 180.0, 50.0)), vec![fill(gradient_paint(glow.clone(), Some(oval), None))]),
|
||||
painted(
|
||||
&mut doc,
|
||||
shapes::rectangle(Rect::new(190.0, 10.0, 280.0, 50.0)),
|
||||
vec![fill(solid(Color::rgb(1.0, 1.0, 0.0))), stroke(gradient_paint(two.clone(), None, None), 6.0)],
|
||||
),
|
||||
painted(
|
||||
&mut doc,
|
||||
shapes::rectangle(Rect::new(10.0, 70.0, 90.0, 110.0)),
|
||||
vec![fill(gradient_paint(sunset(), None, Some("Sunset"))), stroke(gradient_paint(two.clone(), None, None), 6.0)],
|
||||
),
|
||||
];
|
||||
let outer = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 70.0, 180.0, 110.0)), Appearance::default());
|
||||
let hole = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(120.0, 80.0, 160.0, 100.0)), Appearance::default());
|
||||
let mut ring = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(hole)], rule: FillRule::EvenOdd });
|
||||
ring.appearance = Appearance { items: vec![fill(gradient_paint(two.clone(), None, None))], ..Appearance::default() }.into();
|
||||
nodes.push(ring);
|
||||
let mut open = shapes::line((190.0, 110.0).into(), (280.0, 70.0).into());
|
||||
if let Some(sp) = open.subpaths.first_mut() {
|
||||
sp.anchors.push(vectorcraft_geom::Anchor::corner((200.0, 70.0).into()));
|
||||
sp.closed = false;
|
||||
}
|
||||
nodes.push(painted(&mut doc, open, vec![fill(gradient_paint(two, None, None))]));
|
||||
let mixed = ramp(
|
||||
GradientKind::Linear,
|
||||
&[(0.0, Color::gray(0.2), 1.0, 0.5), (0.5, Color::cmyk(1.0, 0.0, 0.0, 0.0), 1.0, 0.5), (1.0, INK.tinted(0.7), 1.0, 0.5)],
|
||||
);
|
||||
let mut mixed_paint = gradient_paint(mixed, None, None);
|
||||
if let Paint::Gradient(g) = &mut mixed_paint
|
||||
&& let Some(s) = g.gradient.stops.last_mut()
|
||||
{
|
||||
s.set_color(INK.tinted(0.7), Some(("Ink".into(), 0.7)));
|
||||
}
|
||||
nodes.push(painted(&mut doc, shapes::rectangle(Rect::new(10.0, 130.0, 90.0, 170.0)), vec![fill(mixed_paint)]));
|
||||
let turned = Affine::translate((103.0, 127.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(0.5);
|
||||
nodes.push(painted(
|
||||
&mut doc,
|
||||
shapes::rectangle(Rect::new(100.0, 130.0, 180.0, 170.0)),
|
||||
vec![fill(Paint::Pattern { pattern: "Dots".into(), xf: turned })],
|
||||
));
|
||||
nodes.push(painted(
|
||||
&mut doc,
|
||||
shapes::rectangle(Rect::new(195.0, 135.0, 235.0, 165.0)),
|
||||
vec![stroke(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, 8.0)],
|
||||
));
|
||||
for n in nodes {
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
/// A gradient paint's swatch link in `doc`: the gradient swatch it names, `-` for none, and
|
||||
/// `not a gradient swatch: name` when it names a swatch of other paint. A link that names no swatch
|
||||
/// of `doc` at all is `-` too: the reader links every gradient instance to its definition's name,
|
||||
/// and a definition of a gradient that isn't a swatch has a name no swatch has.
|
||||
fn link(doc: &Document, swatch: &Option<String>) -> String {
|
||||
match swatch.as_deref().map(|s| (s, doc.swatch(s))) {
|
||||
Some((s, Some(w))) if matches!(w.paint, Paint::Gradient(_)) => s.to_string(),
|
||||
Some((s, Some(_))) => format!("not a gradient swatch: {s}"),
|
||||
Some((_, None)) | None => "-".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint `p` as it draws on `bounds`: a gradient by its kind, stops (place, color, opacity,
|
||||
/// midpoint, link) and its parameter at the corners, edge midpoints and center of `bounds`; a
|
||||
/// pattern by its name and matrix.
|
||||
fn drawn(doc: &Document, p: &Paint, bounds: Rect) -> String {
|
||||
match p {
|
||||
Paint::Gradient(g) => {
|
||||
let geom = g.resolve(bounds);
|
||||
let stops: Vec<String> = g
|
||||
.gradient
|
||||
.stops
|
||||
.iter()
|
||||
.map(|s| {
|
||||
format!(
|
||||
"{} {} {} {} {:?}",
|
||||
r3(f64::from(s.offset)),
|
||||
color(&s.color),
|
||||
r3(f64::from(s.opacity)),
|
||||
r3(f64::from(s.midpoint)),
|
||||
s.swatch
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let (x, y) = ([bounds.x0, bounds.center().x, bounds.x1], [bounds.y0, bounds.center().y, bounds.y1]);
|
||||
let at: Vec<String> =
|
||||
x.iter().flat_map(|x| y.iter().map(move |y| (*x, *y))).map(|(x, y)| r3(geom.param_at(g.gradient.kind, (x, y).into()))).collect();
|
||||
format!("{:?} [{}] link={} at {}", g.gradient.kind, stops.join(", "), link(doc, &g.swatch), at.join(" "))
|
||||
}
|
||||
Paint::Pattern { pattern, xf } => format!("pattern {pattern} {:?}", xf.as_coeffs().map(r3)),
|
||||
other => paint(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// What each object of `nodes` draws: its kind, bounds and paint.
|
||||
fn paints_of(doc: &Document, nodes: &[Arc<Node>], depth: usize, out: &mut Vec<String>) {
|
||||
for n in nodes {
|
||||
let bounds = n.geometric_bounds().unwrap_or(Rect::ZERO);
|
||||
let mut s = format!("{depth} {} {:?}", n.kind_label(), [bounds.x0, bounds.y0, bounds.x1, bounds.y1].map(r3));
|
||||
// Items that paint nothing aren't written.
|
||||
for it in n.appearance.items.iter().filter(|i| !i.paint().is_none()) {
|
||||
s += &match it {
|
||||
AppearanceItem::Fill(f) => format!(" fill({})", drawn(doc, &f.paint, bounds)),
|
||||
AppearanceItem::Stroke(st) => format!(" stroke({} w={})", drawn(doc, &st.paint, st.paint_bounds(bounds)), r3(st.width)),
|
||||
};
|
||||
}
|
||||
out.push(s);
|
||||
// A compound path's paint is its own, not its members'.
|
||||
if !matches!(n.kind, NodeKind::Compound { .. }) {
|
||||
paints_of(doc, n.children().map(Vec::as_slice).unwrap_or_default(), depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `doc`'s art, patterns and gradient and pattern swatches draw.
|
||||
fn gradient_looks(doc: &Document) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
paints_of(doc, &doc.layers, 0, &mut out);
|
||||
for p in doc.patterns.iter() {
|
||||
out.push(format!("pattern {} tile {:?}", p.name, [p.tile.x0, p.tile.y0, p.tile.x1, p.tile.y1].map(r3)));
|
||||
paints_of(doc, &p.art, 1, &mut out);
|
||||
}
|
||||
for s in doc.swatches_iter() {
|
||||
match &s.paint {
|
||||
Paint::Gradient(g) => {
|
||||
out.push(format!("swatch {} {}", s.name, drawn(doc, &Paint::Gradient(Box::new(GradientPaint::new(g.gradient.clone()))), Rect::ZERO)))
|
||||
}
|
||||
Paint::Pattern { pattern, .. } => out.push(format!("swatch {} pattern {pattern}", s.name)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_and_patterns_are_read_back() {
|
||||
let doc = gradients_and_patterns();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
same_lines(&gradient_looks(&read_back(&doc)), &gradient_looks(&doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_and_patterns_write_the_same_bytes_again() {
|
||||
let doc = gradients_and_patterns();
|
||||
let first = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program);
|
||||
let again = export_ai_editing(&read_back(&doc), &opts()).unwrap();
|
||||
same_lines(
|
||||
&String::from_utf8(again.program).unwrap().lines().map(str::to_string).collect::<Vec<_>>(),
|
||||
&String::from_utf8(first.program).unwrap().lines().map(str::to_string).collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_and_patterns_are_written_in_their_forms() {
|
||||
let text = program(&gradients_and_patterns());
|
||||
// The gradients the art uses, then the one only the palette uses; then the patterns.
|
||||
let setup = text.split("%%BeginSetup\n").nth(1).and_then(|s| s.split("%AI5_BeginPalette").next()).unwrap();
|
||||
let markers: Vec<&str> = setup.lines().filter(|l| l.starts_with("%AI") || l.ends_with(" Bn")).collect();
|
||||
assert_eq!(
|
||||
markers,
|
||||
[
|
||||
"4 Bn",
|
||||
"%AI5_BeginGradient: (Sunset)",
|
||||
"%AI5_EndGradient",
|
||||
"%AI5_BeginGradient: (Gradient)",
|
||||
"%AI5_EndGradient",
|
||||
"%AI5_BeginGradient: (Gradient 2)",
|
||||
"%AI5_EndGradient",
|
||||
"%AI5_BeginGradient: (Gradient 3)",
|
||||
"%AI5_EndGradient",
|
||||
"%AI5_Begin_NonPrinting",
|
||||
"1 Bn",
|
||||
"%AI5_BeginGradient: (Glow)",
|
||||
"%AI5_EndGradient",
|
||||
"%AI5_End_NonPrinting--",
|
||||
"%AI3_BeginPattern: (Dots)",
|
||||
"%AI3_EndPattern",
|
||||
"%AI5_Begin_NonPrinting",
|
||||
"%AI3_BeginPattern: (Checks)",
|
||||
"%AI3_EndPattern",
|
||||
"%AI5_End_NonPrinting--",
|
||||
]
|
||||
);
|
||||
// A linear gradient's stops from the last; RGB with its CMYK equivalent, style 2, opacity, 6,
|
||||
// midpoint, place on the ramp.
|
||||
assert!(text.contains("(Sunset) 0 3 Bd\n[\n%_"), "{setup}");
|
||||
assert!(
|
||||
setup.contains(" 0 0 1 2 1 6 50 100 Bs\n") && setup.contains(" 0 1 0 2 0.5 6 50 40 Bs\n") && setup.contains(" 1 0 0 2 1 6 30 0 Bs\n"),
|
||||
"{setup}"
|
||||
);
|
||||
// Gray, CMYK, and a spot color at 70%.
|
||||
assert!(
|
||||
setup.contains("%_0.8 0 1 6 50 0 Bs\n%_1 0 0 0 1 1 6 50 50 Bs\n")
|
||||
|| setup.contains("(Ink) 0.3 3 1 6 50 100 Bs\n%_1 0 0 0 1 1 6 50 50 Bs\n%_0.8 0 1 6 50 0 Bs\n"),
|
||||
"{setup}"
|
||||
);
|
||||
// The tile in the pattern's space, y up, the art on `%_` lines.
|
||||
assert!(setup.contains("%AI3_BeginPattern: (Dots)\n(Dots) 0 -10 10 0\n%_"), "{setup}");
|
||||
assert!(!setup.contains("%_E\n") && setup.contains("\nE\n%AI3_EndPattern\n"), "{setup}");
|
||||
// The swatches.
|
||||
assert!(text.contains("Bb\n2 (Sunset) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n(Sunset) Pc\n"), "{text}");
|
||||
assert!(text.contains("Bb\n0 0 0 0 Bh\n2 (Glow) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n(Glow) Pc\n"), "{text}");
|
||||
assert!(text.contains("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Dots) Pc\n"), "{text}");
|
||||
// The instances: the stroked rectangle's fill placed at 29.9° from 0.15 of the rectangle's extent
|
||||
// that way, 0.52 of it long, with `Xm`, caps and a `Bm` for each pair of stops, `b` and `2 BB`; the
|
||||
// stroke's `1 Bb`; the radial's focal point and `Bm`.
|
||||
let art = text.split("%AI5_BeginLayer").nth(1).unwrap();
|
||||
assert!(art.contains("Bb\n1 (Sunset) 0.1529 0 29.8989 0.5167 1 0 0 1 0 0 1 Bg\n") && art.contains(" Xm\n"), "{art}");
|
||||
let sunset_instance = art.split("Bb\n1 (Sunset)").nth(1).unwrap();
|
||||
let ops: Vec<&str> = sunset_instance.lines().take(7).map(|l| l.rsplit(' ').next().unwrap_or(l)).collect();
|
||||
assert_eq!(ops, ["Bg", "Xm", "Bc", "Bm", "Bm", "Bc", "b"], "{sunset_instance}");
|
||||
assert!(sunset_instance.contains("\nb\n2 BB\n"), "{sunset_instance}");
|
||||
assert!(art.contains("1 Bb\n1 (Gradient 2) "), "{art}");
|
||||
assert!(art.contains("2 BB\n1 Bb\n"), "fill and stroke: {art}");
|
||||
// The radial (40 wide, 20 high) as the unit circle through its matrix: the focal point 10 right of
|
||||
// the center and 5 up, at 45° and 0.35 of the radius on that circle.
|
||||
assert!(art.contains("Bb\n10 5 45 0.3536 Bh\n1 (Gradient) 0 0 0 1 40 0 0 20 0 0 1 Bg\n40 0 0 -20 140 -30 Bm\nf\n0 BB\n"), "{art}");
|
||||
// The tile's box first in a pattern's definition.
|
||||
assert!(setup.contains("(Dots) 0 -10 10 0\n%_0 A\n%_0 Xw\n%_0 XR\n%_0 0 m\n"), "{setup}");
|
||||
assert!(setup.contains("%_n\n%_/ArtDictionary :\n%_1 /Bool (AIPattern_Editor_Backing_Tile_Rect) ,\n%_;\n%_\n"), "{setup}");
|
||||
assert!(art.contains("F\n0 BB\n"), "the open path: {art}");
|
||||
assert!(art.contains(" p\n") && art.contains(" P\n"), "{art}");
|
||||
}
|
||||
|
||||
/// A clipping path filled with a gradient keeps it: the reader reads it as the clipping path and
|
||||
/// its fill as a path of its own under the art.
|
||||
#[test]
|
||||
fn a_clipping_path_keeps_its_gradient() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut clip = clipping(&mut doc, shapes::ellipse(Rect::new(10.0, 10.0, 90.0, 60.0)));
|
||||
clip.appearance = Appearance::basic(gradient_paint(sunset(), None, None), Paint::None, 0.0).into();
|
||||
let inside = square(&mut doc, Rect::new(0.0, 0.0, 50.0, 40.0), Color::rgb(0.0, 0.0, 1.0));
|
||||
let g = group(&mut doc, vec![clip, inside], true);
|
||||
doc.insert(Some(layer), usize::MAX, g).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::CLIP_PAINT), "{:?}", out.warnings);
|
||||
let text = program(&doc);
|
||||
assert!(text.contains("h\nW\nBb\n0 (Gradient) "), "{text}");
|
||||
let back = read_back(&doc);
|
||||
let NodeKind::Group { children, clip: true } = &back.layers[0].children().unwrap()[0].kind else { panic!("a clipping group") };
|
||||
let fill = &children[1];
|
||||
let bounds = Rect::new(10.0, 10.0, 90.0, 60.0);
|
||||
let want = drawn(&doc, &gradient_paint(sunset(), None, None), bounds);
|
||||
assert_eq!(drawn(&back, fill.appearance.items[0].paint(), bounds), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_gradients_are_written_as_their_average_color() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let g = ramp(GradientKind::Freeform, &[(0.0, Color::rgb(1.0, 1.0, 1.0), 1.0, 0.5), (1.0, Color::rgb(0.0, 0.0, 0.0), 1.0, 0.5)]);
|
||||
let n = painted(
|
||||
&mut doc,
|
||||
shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)),
|
||||
vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(g, None, None)))],
|
||||
);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::GRADIENTS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
assert_eq!(paint(back.layers[0].children().unwrap()[0].appearance.items[0].paint()), paint(&solid(Color::rgb(0.5, 0.5, 0.5))));
|
||||
}
|
||||
|
||||
/// A gradient that dithers or mixes perceptually is written as a classic one; a stop linked to a
|
||||
/// global process color is written as its color; each with a warning.
|
||||
#[test]
|
||||
fn gradient_options_and_links_the_data_cant_hold_are_reported() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut g = sunset();
|
||||
g.dither = true;
|
||||
let n = painted(
|
||||
&mut doc,
|
||||
shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)),
|
||||
vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(g, None, None)))],
|
||||
);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings, [writer::GRADIENT_MIX]);
|
||||
// A swatch of such a gradient that nothing uses.
|
||||
let mut swatch_only = Document::new(200.0, 100.0);
|
||||
let mut dithered = sunset();
|
||||
dithered.dither = true;
|
||||
let mut swatches: Vec<Swatch> = swatch_only.swatches.iter().cloned().collect();
|
||||
swatches.push(Swatch { name: "Dithered".into(), paint: gradient_paint(dithered, None, None), global: false, spot: false });
|
||||
swatch_only.swatches = swatches.into();
|
||||
let out = export_ai_editing(&swatch_only, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::GRADIENT_MIX), "{:?}", out.warnings);
|
||||
// A gradient along a stroke: the stroke's outline in its average color.
|
||||
let mut along = Document::new(200.0, 100.0);
|
||||
let layer = along.layers[0].id;
|
||||
let mut st = StrokeLayer::new(gradient_paint(sunset(), None, None), 4.0);
|
||||
st.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along;
|
||||
let n = painted(&mut along, shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), vec![AppearanceItem::Stroke(st)]);
|
||||
along.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&along, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::GRADIENTS), "{:?}", out.warnings);
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect();
|
||||
swatches.push(Swatch { name: "Brand".into(), paint: solid(BRAND), global: true, spot: false });
|
||||
doc.swatches = swatches.into();
|
||||
let mut g = sunset();
|
||||
g.method = vectorcraft_color::gradient::GradientMethod::Perceptual;
|
||||
if let Some(s) = g.stops.first_mut() {
|
||||
s.set_color(BRAND, Some(("Brand".into(), 1.0)));
|
||||
}
|
||||
let n = painted(
|
||||
&mut doc,
|
||||
shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)),
|
||||
vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(g, None, None)))],
|
||||
);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings, [writer::GRADIENT_MIX, writer::STOP_LINKS]);
|
||||
let back = read_back(&doc);
|
||||
let Paint::Gradient(g) = back.layers[0].children().unwrap()[0].appearance.items[0].paint() else { panic!("a gradient") };
|
||||
assert_eq!((color(&g.gradient.stops[0].color), g.gradient.stops[0].swatch.as_deref()), (color(&BRAND), None));
|
||||
}
|
||||
|
||||
/// A pattern whose art reaches past its tile is written as its tiles cut to the shape: a definition
|
||||
/// would cut its art off at the tile's edge.
|
||||
#[test]
|
||||
fn a_pattern_whose_art_reaches_past_its_tile_is_written_as_its_tiles() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let big = square(&mut doc, Rect::new(-2.0, -2.0, 6.0, 6.0), Color::rgb(1.0, 0.0, 0.0));
|
||||
let mut over = vectorcraft_doc::PatternDef::new("Over", vec![Arc::new(big)]);
|
||||
over.tile = Rect::new(0.0, 0.0, 10.0, 10.0);
|
||||
doc.patterns.push(over);
|
||||
let n = painted(
|
||||
&mut doc,
|
||||
shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)),
|
||||
vec![AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: "Over".into(), xf: Affine::IDENTITY }))],
|
||||
);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PATTERNS), "{:?}", out.warnings);
|
||||
assert!(!program(&doc).contains("%AI3_BeginPattern"));
|
||||
}
|
||||
|
||||
/// A pattern fill in another pattern's tile is written there as its tiles cut to the shape; the
|
||||
/// outer pattern is defined and fills the shape.
|
||||
#[test]
|
||||
fn a_pattern_fill_in_a_pattern_tile_is_written_as_its_tiles() {
|
||||
let mut doc = gradients_and_patterns();
|
||||
let layer = doc.layers[0].id;
|
||||
let inner = painted(
|
||||
&mut doc,
|
||||
shapes::rectangle(Rect::new(2.0, 2.0, 18.0, 18.0)),
|
||||
vec![AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }))],
|
||||
);
|
||||
let mut nest = vectorcraft_doc::PatternDef::new("Nest", vec![Arc::new(inner)]);
|
||||
nest.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
doc.patterns.push(nest);
|
||||
let n = painted(
|
||||
&mut doc,
|
||||
shapes::rectangle(Rect::new(250.0, 130.0, 290.0, 170.0)),
|
||||
vec![AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: "Nest".into(), xf: Affine::IDENTITY }))],
|
||||
);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PATTERNS), "{:?}", out.warnings);
|
||||
let text = program(&doc);
|
||||
assert!(text.contains("%AI3_BeginPattern: (Nest)") && text.contains("%AI3_BeginPattern: (Dots)"));
|
||||
let back = read_back(&doc);
|
||||
let filled = back.layers[0].children().unwrap().last().unwrap();
|
||||
assert!(matches!(filled.appearance.items[0].paint(), Paint::Pattern { pattern, .. } if pattern == "Nest"));
|
||||
// Nest's tile: the dots' tiles (a red square, a gradient circle) cut to its square.
|
||||
let mut all = vec![];
|
||||
paths(&back.pattern("Nest").unwrap().art, &mut all);
|
||||
assert!(all.len() > 2, "{}", all.len());
|
||||
assert!(all.iter().all(|p| !matches!(p.appearance.items[0].paint(), Paint::Pattern { .. })));
|
||||
assert!(all.iter().any(|p| paint(p.appearance.items[0].paint()) == paint(&solid(Color::rgb(1.0, 0.0, 0.0)))));
|
||||
}
|
||||
|
||||
/// A 200 × 100 document with one object of `items` on `path`, and only the None swatch.
|
||||
fn one_object(path: PathData, items: Vec<AppearanceItem>) -> Document {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
doc.swatches = doc.swatches.iter().filter(|s| matches!(s.paint, Paint::None)).cloned().collect::<Vec<_>>().into();
|
||||
doc.swatch_groups = Default::default();
|
||||
let layer = doc.layers[0].id;
|
||||
let n = painted(&mut doc, path, items);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// What `doc`'s art draws (a palette of only the None swatch reads back as the default swatches).
|
||||
fn art_looks(doc: &Document) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
paints_of(doc, &doc.layers, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn swatches_plus(doc: &mut Document, more: Vec<Swatch>) {
|
||||
let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect();
|
||||
swatches.extend(more);
|
||||
doc.swatches = swatches.into();
|
||||
}
|
||||
|
||||
/// `Bg` places each gradient on its path: a radial one 30 right of and 10 above the middle of a
|
||||
/// 120 × 60 rectangle, 20 in radius, by its unit circle's matrix, its center's place as fractions of
|
||||
/// the bounds and its radius as a multiple of √((1.5² + 2²) / 2) (the center's distances to the
|
||||
/// right side and to the bottom in that circle's units); and a linear one at −45° on a triangle,
|
||||
/// from a quarter of the triangle's extent that way, half of it long. In a pattern's tile the same
|
||||
/// two are placed mirrored top to bottom: the radial's center 10 below the middle, its radius a
|
||||
/// multiple of √((1.5² + 1²) / 2), and the linear one at 45°.
|
||||
#[test]
|
||||
fn bg_places_gradients_on_their_paths() {
|
||||
use vectorcraft_color::gradient::GradientGeom;
|
||||
let radial = ramp(GradientKind::Radial, &[(0.0, Color::rgb(1.0, 1.0, 1.0), 1.0, 0.5), (1.0, Color::rgb(0.0, 0.0, 1.0), 1.0, 0.5)]);
|
||||
let off_center = GradientGeom { start: (90.0, 20.0).into(), end: (110.0, 20.0).into(), aspect: 1.0, focal: None };
|
||||
let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p));
|
||||
let mut doc = one_object(shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 60.0)), vec![fill(gradient_paint(radial.clone(), Some(off_center), None))]);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut triangle = shapes::line((130.0, 10.0).into(), (190.0, 10.0).into());
|
||||
if let Some(sp) = triangle.subpaths.first_mut() {
|
||||
sp.anchors.push(vectorcraft_geom::Anchor::corner((130.0, 40.0).into()));
|
||||
sp.closed = true;
|
||||
}
|
||||
let turned = GradientGeom { start: (145.0, 10.0).into(), end: (160.0, 25.0).into(), aspect: 1.0, focal: None };
|
||||
let n = painted(&mut doc, triangle.clone(), vec![fill(gradient_paint(sunset(), Some(turned), None))]);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let in_tile = painted(&mut doc, shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 60.0)), vec![fill(gradient_paint(radial, Some(off_center), None))]);
|
||||
let slant = painted(&mut doc, triangle, vec![fill(gradient_paint(sunset(), Some(turned), None))]);
|
||||
let mut tile = vectorcraft_doc::PatternDef::new("Tile", vec![Arc::new(in_tile), Arc::new(slant)]);
|
||||
tile.tile = Rect::new(0.0, 0.0, 200.0, 60.0);
|
||||
doc.patterns.push(tile);
|
||||
swatches_plus(
|
||||
&mut doc,
|
||||
vec![Swatch { name: "Tile".into(), paint: Paint::Pattern { pattern: "Tile".into(), xf: Affine::IDENTITY }, global: false, spot: false }],
|
||||
);
|
||||
let n = painted(
|
||||
&mut doc,
|
||||
shapes::rectangle(Rect::new(130.0, 50.0, 190.0, 90.0)),
|
||||
vec![fill(Paint::Pattern { pattern: "Tile".into(), xf: Affine::IDENTITY })],
|
||||
);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
let text = program(&doc);
|
||||
let art = text.split("%AI5_BeginLayer").nth(1).unwrap();
|
||||
assert!(art.contains("Bb\n0 0 0 0 Bh\n1 (Gradient) 0.25 -0.1667 0 0.5657 20 0 0 20 0 0 1 Bg\n"), "{art}");
|
||||
assert!(art.contains("Bb\n1 (Gradient 2) 0.25 0 -45 0.5 1 0 0 1 0 0 1 Bg\n"), "{art}");
|
||||
let setup = text.split("%AI3_BeginPattern: (Tile)").nth(1).unwrap();
|
||||
assert!(setup.contains("%_Bb\n%_0 0 0 0 Bh\n%_1 (Gradient) 0.25 0.1667 0 0.7845 20 0 0 20 0 0 1 Bg\n"), "{setup}");
|
||||
assert!(setup.contains("%_Bb\n%_1 (Gradient 2) 0.25 0 45 0.5 1 0 0 1 0 0 1 Bg\n"), "{setup}");
|
||||
same_lines(&gradient_looks(&read_back(&doc)), &gradient_looks(&doc));
|
||||
}
|
||||
|
||||
/// A definition of a gradient that isn't a swatch takes a name that no swatch and no pattern has,
|
||||
/// so that its instances don't read back linked to another swatch.
|
||||
#[test]
|
||||
fn gradient_definitions_skip_the_names_of_swatches_and_patterns() {
|
||||
let mut doc = gradients_and_patterns();
|
||||
let check = square(&mut doc, Rect::new(0.0, 0.0, 5.0, 5.0), Color::BLACK);
|
||||
let mut taken = vectorcraft_doc::PatternDef::new("Gradient 2", vec![Arc::new(check)]);
|
||||
taken.tile = Rect::new(0.0, 0.0, 10.0, 10.0);
|
||||
doc.patterns.push(taken);
|
||||
swatches_plus(
|
||||
&mut doc,
|
||||
vec![
|
||||
Swatch { name: "Gradient".into(), paint: solid(Color::rgb(0.0, 0.5, 0.0)), global: false, spot: false },
|
||||
Swatch {
|
||||
name: "Gradient 2".into(),
|
||||
paint: Paint::Pattern { pattern: "Gradient 2".into(), xf: Affine::IDENTITY },
|
||||
global: false,
|
||||
spot: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
let text = program(&doc);
|
||||
let names: Vec<&str> = text.lines().filter_map(|l| l.strip_prefix("%AI5_BeginGradient: ")).collect();
|
||||
assert_eq!(names, ["(Sunset)", "(Gradient 3)", "(Gradient 4)", "(Gradient 5)", "(Glow)"]);
|
||||
same_lines(&gradient_looks(&read_back(&doc)), &gradient_looks(&doc));
|
||||
}
|
||||
|
||||
/// A hard edge: a linear gradient whose stops all lie at one place is written from its last stop,
|
||||
/// and reads back with each stop on its side.
|
||||
#[test]
|
||||
fn a_linear_gradient_with_every_stop_at_one_place_keeps_its_sides() {
|
||||
let edge = ramp(GradientKind::Linear, &[(0.3, Color::rgb(1.0, 0.0, 0.0), 1.0, 0.4), (0.3, Color::rgb(0.0, 0.0, 1.0), 1.0, 0.7)]);
|
||||
let doc = one_object(
|
||||
shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 50.0)),
|
||||
vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(edge, None, None)))],
|
||||
);
|
||||
let text = program(&doc);
|
||||
let (blue, red) = (text.find(" 0 0 1 2 1 6 70 30 Bs\n").unwrap(), text.find(" 1 0 0 2 1 6 40 30 Bs\n").unwrap());
|
||||
assert!(blue < red, "{text}");
|
||||
same_lines(&art_looks(&read_back(&doc)), &art_looks(&doc));
|
||||
}
|
||||
|
||||
/// Gradients without stops or with more than 256, without a ramp's length or flattened to a line
|
||||
/// are written as their average color with a warning that says so, not the one for freeform
|
||||
/// gradients.
|
||||
#[test]
|
||||
fn gradients_that_cant_be_placed_are_reported_as_such() {
|
||||
use vectorcraft_color::gradient::GradientGeom;
|
||||
let point = GradientGeom { start: (20.0, 20.0).into(), end: (20.0, 20.0).into(), aspect: 1.0, focal: None };
|
||||
let flat = GradientGeom { start: (20.0, 20.0).into(), end: (40.0, 20.0).into(), aspect: 0.0, focal: None };
|
||||
let radial = ramp(GradientKind::Radial, &[(0.0, Color::rgb(1.0, 1.0, 1.0), 1.0, 0.5), (1.0, Color::rgb(0.0, 0.0, 0.0), 1.0, 0.5)]);
|
||||
// More stops than the `.ai` reader reads.
|
||||
let many: Vec<(f32, Color, f32, f32)> = (0..257).map(|i| (i as f32 / 256.0, Color::gray(i as f32 / 256.0), 1.0, 0.5)).collect();
|
||||
for p in [
|
||||
gradient_paint(sunset(), Some(point), None),
|
||||
gradient_paint(radial, Some(flat), None),
|
||||
gradient_paint(Gradient::new(GradientKind::Linear, vec![]), None, None),
|
||||
gradient_paint(ramp(GradientKind::Linear, &many), None, None),
|
||||
] {
|
||||
let doc = one_object(shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), vec![AppearanceItem::Fill(FillLayer::new(p.clone()))]);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings, [writer::GRADIENT_SHAPES], "{p:?}");
|
||||
let back = read_back(&doc);
|
||||
assert!(matches!(back.layers[0].children().unwrap()[0].appearance.items[0].paint(), Paint::Solid { .. }), "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient across a stroke is written as the stroke's outline in its average color, with the
|
||||
/// warning.
|
||||
#[test]
|
||||
fn a_gradient_across_a_stroke_is_its_average_color() {
|
||||
let mut st = StrokeLayer::new(gradient_paint(sunset(), None, None), 4.0);
|
||||
st.gradient_mode = vectorcraft_doc::StrokeGradientMode::Across;
|
||||
let doc = one_object(shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), vec![AppearanceItem::Stroke(st)]);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::GRADIENTS), "{:?}", out.warnings);
|
||||
assert!(!program(&doc).contains("Bb\n"));
|
||||
let mut all = vec![];
|
||||
paths(read_back(&doc).layers[0].children().unwrap(), &mut all);
|
||||
assert!(!all.is_empty() && all.iter().all(|p| p.appearance.items.iter().all(|i| matches!(i.paint(), Paint::Solid { .. }))), "{all:?}");
|
||||
}
|
||||
|
||||
/// Patterns whose tiles are hexagons or bricks are written as their tiles cut to the shape, with the
|
||||
/// warning, and aren't defined.
|
||||
#[test]
|
||||
fn hex_and_brick_patterns_are_written_as_their_tiles() {
|
||||
for tile_type in [
|
||||
vectorcraft_doc::TileType::HexByRow,
|
||||
vectorcraft_doc::TileType::HexByColumn,
|
||||
vectorcraft_doc::TileType::BrickByRow { offset: 0.5 },
|
||||
vectorcraft_doc::TileType::BrickByColumn { offset: 0.5 },
|
||||
] {
|
||||
let mut doc = one_object(
|
||||
shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)),
|
||||
vec![AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: "Cells".into(), xf: Affine::IDENTITY }))],
|
||||
);
|
||||
let dot = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::rgb(1.0, 0.0, 0.0));
|
||||
doc.patterns.push(vectorcraft_doc::PatternDef {
|
||||
tile: Rect::new(0.0, 0.0, 10.0, 10.0),
|
||||
tile_type,
|
||||
..vectorcraft_doc::PatternDef::new("Cells", vec![Arc::new(dot)])
|
||||
});
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::PATTERNS), "{tile_type:?}: {:?}", out.warnings);
|
||||
assert!(!program(&doc).contains("%AI3_BeginPattern"), "{tile_type:?}");
|
||||
let mut all = vec![];
|
||||
paths(read_back(&doc).layers[0].children().unwrap(), &mut all);
|
||||
let red = paint(&solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert!(all.iter().any(|p| p.appearance.items.iter().any(|i| paint(i.paint()) == red)), "{tile_type:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An open path filled and stroked with gradients: the fill's instance ends `1 BB`, the stroke's
|
||||
/// `1 Bb` follows, and the open path's `B` ends `1 BB`.
|
||||
#[test]
|
||||
fn an_open_path_keeps_its_gradient_fill_and_stroke() {
|
||||
let two = ramp(GradientKind::Linear, &[(0.0, Color::rgb(1.0, 1.0, 0.0), 1.0, 0.5), (1.0, Color::rgb(0.0, 0.0, 0.0), 1.0, 0.5)]);
|
||||
let mut open = shapes::line((20.0, 80.0).into(), (100.0, 20.0).into());
|
||||
if let Some(sp) = open.subpaths.first_mut() {
|
||||
sp.anchors.push(vectorcraft_geom::Anchor::corner((150.0, 70.0).into()));
|
||||
sp.closed = false;
|
||||
}
|
||||
let doc = one_object(
|
||||
open,
|
||||
vec![
|
||||
AppearanceItem::Fill(FillLayer::new(gradient_paint(sunset(), None, None))),
|
||||
AppearanceItem::Stroke(StrokeLayer::new(gradient_paint(two, None, None), 3.0)),
|
||||
],
|
||||
);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
let text = program(&doc);
|
||||
assert!(text.contains("\n1 BB\n1 Bb\n1 (Gradient 2) ") && text.contains("\nB\n1 BB\n"), "{text}");
|
||||
same_lines(&art_looks(&read_back(&doc)), &art_looks(&doc));
|
||||
}
|
||||
|
||||
/// A clipping path stroked with a gradient keeps it.
|
||||
#[test]
|
||||
fn a_clipping_path_keeps_its_gradient_stroke() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut clip = clipping(&mut doc, shapes::ellipse(Rect::new(10.0, 10.0, 90.0, 60.0)));
|
||||
clip.appearance = Appearance::basic(Paint::None, gradient_paint(sunset(), None, None), 3.0).into();
|
||||
let inside = square(&mut doc, Rect::new(0.0, 0.0, 50.0, 40.0), Color::rgb(0.0, 0.0, 1.0));
|
||||
let g = group(&mut doc, vec![clip, inside], true);
|
||||
doc.insert(Some(layer), usize::MAX, g).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
let text = program(&doc);
|
||||
assert!(text.contains("h\nW\n1 Bb\n0 (Gradient) "), "{text}");
|
||||
let back = read_back(&doc);
|
||||
let mut want = vec![];
|
||||
paints_of(&doc, doc.layers[0].children().unwrap(), 0, &mut want);
|
||||
let mut got = vec![];
|
||||
paints_of(&back, back.layers[0].children().unwrap(), 0, &mut got);
|
||||
let strokes = |v: &[String]| {
|
||||
v.iter().filter(|l| l.contains(" stroke(Linear")).map(|l| l.split(" stroke(").nth(1).unwrap_or_default().to_string()).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(strokes(&got), strokes(&want), "{got:#?}");
|
||||
}
|
||||
|
||||
/// In a CMYK document the gradient's CMYK stops are written as CMYK and read back the same.
|
||||
#[test]
|
||||
fn a_cmyk_documents_gradients_keep_their_stops() {
|
||||
let cmyk = ramp(GradientKind::Linear, &[(0.0, Color::cmyk(0.0, 0.6, 1.0, 0.0), 1.0, 0.5), (1.0, Color::cmyk(1.0, 0.0, 0.0, 0.2), 0.5, 0.25)]);
|
||||
let mut doc = one_object(
|
||||
shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 50.0)),
|
||||
vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(cmyk, None, None)))],
|
||||
);
|
||||
doc.color_mode = vectorcraft_doc::ColorMode::Cmyk;
|
||||
let text = program(&doc);
|
||||
assert!(text.contains("%AI9_ColorModel: 2\n"), "{text}");
|
||||
assert!(text.contains("%_1 0 0 0.2 1 0.5 6 25 100 Bs\n%_0 0.6 1 0 1 1 6 50 0 Bs\n"), "{text}");
|
||||
same_lines(&art_looks(&read_back(&doc)), &art_looks(&doc));
|
||||
}
|
||||
|
||||
/// A compound path's gradient instance is on its last subpath only.
|
||||
#[test]
|
||||
fn a_compound_paths_gradient_is_on_its_last_subpath() {
|
||||
let text = program(&gradients_and_patterns());
|
||||
let compound = text.split("*u\n").nth(1).and_then(|s| s.split("*U\n").next()).unwrap();
|
||||
assert_eq!(compound.matches("Bb\n").count(), 1, "{compound}");
|
||||
let last_subpath = compound.rfind(" m\n").unwrap();
|
||||
assert!(compound.find("Bb\n").unwrap() > last_subpath, "{compound}");
|
||||
assert_eq!(compound.matches(" m\n").count(), 2, "{compound}");
|
||||
}
|
||||
|
||||
/// Freeform gradient swatches and swatches of patterns that can't be defined are left out with the
|
||||
/// warning; a color group counts only the swatches written.
|
||||
#[test]
|
||||
fn swatches_the_data_cant_hold_are_left_out() {
|
||||
let mut doc = gradients_and_patterns();
|
||||
let dot = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::rgb(1.0, 0.0, 0.0));
|
||||
doc.patterns.push(vectorcraft_doc::PatternDef {
|
||||
tile: Rect::new(0.0, 0.0, 10.0, 10.0),
|
||||
tile_type: vectorcraft_doc::TileType::BrickByRow { offset: 0.5 },
|
||||
..vectorcraft_doc::PatternDef::new("Brick", vec![Arc::new(dot)])
|
||||
});
|
||||
let free = ramp(GradientKind::Freeform, &[(0.0, Color::rgb(1.0, 1.0, 1.0), 1.0, 0.5), (1.0, Color::rgb(0.0, 0.0, 0.0), 1.0, 0.5)]);
|
||||
let swatch = |name: &str, paint: Paint| Swatch { name: name.into(), paint, global: false, spot: false };
|
||||
doc.swatch_groups = vec![SwatchGroup {
|
||||
name: "Mixed".into(),
|
||||
swatches: vec![
|
||||
swatch("Free", gradient_paint(free.clone(), None, None)),
|
||||
swatch("Dawn", gradient_paint(sunset(), None, None)),
|
||||
swatch("Brick", Paint::Pattern { pattern: "Brick".into(), xf: Affine::IDENTITY }),
|
||||
swatch("Dots 2", Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }),
|
||||
],
|
||||
}]
|
||||
.into();
|
||||
swatches_plus(&mut doc, vec![swatch("Free 2", gradient_paint(free, None, None))]);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings, [writer::SWATCHES], "{:?}", out.warnings);
|
||||
let text = program(&doc);
|
||||
assert!(text.contains("0 (Mixed) 2 Pg\n"), "{text}");
|
||||
for name in ["(Free) Pc", "(Free 2) Pc", "(Brick) Pc", "%AI3_BeginPattern: (Brick)"] {
|
||||
assert!(!text.contains(name), "{name}");
|
||||
}
|
||||
let back = read_back(&doc);
|
||||
let group = back.swatch_groups.iter().find(|g| g.name == "Mixed").unwrap();
|
||||
assert_eq!(group.swatches.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Dawn", "Dots 2"]);
|
||||
}
|
||||
|
||||
/// A pattern swatch that moves, turns or scales its pattern is written with its pattern, and the
|
||||
/// placement is reported left out.
|
||||
#[test]
|
||||
fn a_pattern_swatchs_own_placement_is_reported_left_out() {
|
||||
let mut doc = gradients_and_patterns();
|
||||
let moved = Paint::Pattern { pattern: "Dots".into(), xf: Affine::scale(2.0) };
|
||||
swatches_plus(&mut doc, vec![Swatch { name: "Big Dots".into(), paint: moved, global: false, spot: false }]);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings, [writer::SWATCH_PLACEMENT]);
|
||||
assert!(program(&doc).contains("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Big Dots) Pc\n"));
|
||||
}
|
||||
|
||||
@@ -12,9 +12,14 @@ use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Shape, SubPath};
|
||||
use vectorcraft_pathops::BoolOp;
|
||||
|
||||
use crate::ai_ops::{self, average, unit};
|
||||
use crate::ai_ops::{self, unit};
|
||||
use crate::ps;
|
||||
|
||||
mod paints;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT};
|
||||
|
||||
/// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left
|
||||
/// out). Groups and layers need no limit of their own: a document is never deeper than
|
||||
/// [`vectorcraft_doc::MAX_DEPTH`].
|
||||
@@ -25,8 +30,7 @@ const AVERAGE_SIDE: u32 = 64;
|
||||
const MAX_AVERAGED_PIXELS: u64 = 1 << 24;
|
||||
|
||||
pub(super) const TYPE: &str = "type is written as its outlines in the editing data for other apps";
|
||||
pub(super) const GRADIENTS: &str = "gradients are written as their average color in the editing data for other apps";
|
||||
const PATTERNS: &str = "pattern fills are written as their tiles cut to the shape in the editing data for other apps";
|
||||
pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps";
|
||||
pub(super) const PATTERN_TILES: &str = "pattern fills with more tiles than a fill places are written filled gray in the editing data for other apps";
|
||||
pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose clipping object isn't a path, and the clipping groups inside them, are written as their art cut to that object in the editing data for other apps";
|
||||
pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths that a plain fill and line can't draw are written as paths of their own inside the clipping (fills under the clipped art, strokes over it) in the editing data for other apps";
|
||||
@@ -43,7 +47,7 @@ pub(super) const DEEP_MASKS: &str =
|
||||
const GROUPED_LAYER: &str =
|
||||
"the transparency and opacity masks of layers that an effect made into a group are left out of the editing data for other apps";
|
||||
pub(super) const EFFECTS: &str = "live effects other than geometry effects are left out of the editing data for other apps";
|
||||
const SWATCHES: &str = "gradient and pattern swatches are left out of the editing data for other apps";
|
||||
pub(super) const SWATCHES: &str = "freeform gradient swatches, gradient swatches of more than 256 stops, and pattern swatches whose patterns the data can't define, are left out of the editing data for other apps";
|
||||
pub(super) const SYMBOL_LIMIT: &str = "symbol instances past 16,000,000 objects of symbol art in all are left out of the editing data for other apps";
|
||||
const TEMPLATES: &str = "template layers are written as dimmed layers that don't print in the editing data for other apps";
|
||||
|
||||
@@ -127,13 +131,38 @@ pub(super) struct Writer<'a> {
|
||||
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
||||
/// The objects of the symbol art written so far, within [`vectorcraft_doc::MAX_SYMBOL_NODES`].
|
||||
symbol_nodes: u64,
|
||||
/// The document → the space gradients' matrices (`Xm`, `Bm`, `Bc`) and focal points are
|
||||
/// written in: art space, and in a pattern's definition [`Self::tile_grad_space`].
|
||||
grad_space: Affine,
|
||||
/// The pattern's space (y down) → the space a pattern's definition writes gradients' matrices
|
||||
/// in: the canvas's (y down), from art space's origin there.
|
||||
tile_grad_space: Affine,
|
||||
/// The document → the canvas (y down), the space of a gradient's transformation in `Bg`; in a
|
||||
/// pattern's definition, the pattern's space turned y up.
|
||||
canvas: Affine,
|
||||
/// The gradient definitions, in the order they are first used.
|
||||
gradients: Vec<paints::GradientDef>,
|
||||
/// The pattern definitions written, each after those its art uses.
|
||||
patterns: Vec<paints::PatternOut>,
|
||||
/// The patterns whose definitions are being written.
|
||||
defining: Vec<String>,
|
||||
/// Writing the palette: the definitions it adds are for its swatches only.
|
||||
in_palette: bool,
|
||||
}
|
||||
|
||||
impl<'a> Writer<'a> {
|
||||
pub(super) fn new(doc: &'a Document, to_art: Affine) -> Self {
|
||||
/// A writer of `doc`'s art through `to_art`; `origin` is art space's origin on the canvas.
|
||||
pub(super) fn new(doc: &'a Document, to_art: Affine, origin: (f64, f64)) -> Self {
|
||||
Self {
|
||||
doc,
|
||||
to_art,
|
||||
grad_space: to_art,
|
||||
tile_grad_space: Affine::translate((-origin.0, origin.1)),
|
||||
canvas: Affine::new([1.0, 0.0, 0.0, -1.0, origin.0, origin.1]) * to_art,
|
||||
gradients: vec![],
|
||||
patterns: vec![],
|
||||
defining: vec![],
|
||||
in_palette: false,
|
||||
cms: vectorcraft_color::cms::active(),
|
||||
body: String::new(),
|
||||
warnings: vec![],
|
||||
@@ -560,16 +589,17 @@ impl<'a> Writer<'a> {
|
||||
self.warn_cut(Some(first));
|
||||
return self.clip_contents(children);
|
||||
};
|
||||
let painted = plain_paint(first);
|
||||
if visible_items(first).iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) {
|
||||
let items = self.items(first, path.to_bezpath().bounding_box());
|
||||
let painted = self.plain_paint(&items);
|
||||
if items.iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) {
|
||||
self.warn(ITEM_TRANSPARENCY);
|
||||
}
|
||||
// Paint the clipping path can't carry: its fills under the others, its strokes over them.
|
||||
let (fills, strokes): (Vec<&AppearanceItem>, Vec<&AppearanceItem>) =
|
||||
if painted.is_none() { visible_items(first).into_iter().partition(|i| matches!(i, AppearanceItem::Fill(_))) } else { (vec![], vec![]) };
|
||||
if painted.is_none() { items.iter().partition(|i| matches!(i, AppearanceItem::Fill(_))) } else { (vec![], vec![]) };
|
||||
if painted.is_none() {
|
||||
self.warn(CLIP_PAINT);
|
||||
if first.extras().mask.is_some() && !visible_items(first).is_empty() {
|
||||
if first.extras().mask.is_some() && !items.is_empty() {
|
||||
self.warn(CLIP_PAINT_MASKS);
|
||||
}
|
||||
}
|
||||
@@ -588,10 +618,13 @@ impl<'a> Writer<'a> {
|
||||
if several {
|
||||
self.line("*u");
|
||||
}
|
||||
for sp in subpaths {
|
||||
let last = subpaths.len().saturating_sub(1);
|
||||
let outline = path.to_bezpath();
|
||||
for (i, sp) in subpaths.into_iter().enumerate() {
|
||||
ai_ops::subpath(&mut self.body, self.to_art, sp);
|
||||
let op = if sp.closed { op } else { op.to_ascii_uppercase() };
|
||||
let _ = writeln!(self.body, "{}\nW\n{op}", if sp.closed { 'h' } else { 'H' });
|
||||
let _ = writeln!(self.body, "{}\nW", if sp.closed { 'h' } else { 'H' });
|
||||
let gradients = if i == last { (gradient(fill.map(|f| &f.paint)), gradient(stroke.map(|s| &s.paint))) } else { (None, None) };
|
||||
self.painted(op, sp.closed, gradients, stroke.is_some(), true, &outline);
|
||||
}
|
||||
if several {
|
||||
self.line("*U");
|
||||
@@ -658,11 +691,11 @@ impl<'a> Writer<'a> {
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
let items = visible_items(n);
|
||||
let items = self.items(n, path.to_bezpath().bounding_box());
|
||||
if items.iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) {
|
||||
self.warn(ITEM_TRANSPARENCY);
|
||||
}
|
||||
match (plain_paint(n), items.as_slice()) {
|
||||
match (self.plain_paint(&items), items.as_slice()) {
|
||||
(Some((fill, stroke)), _) => self.path_object(path, rule, fill, stroke, xy),
|
||||
(None, [one]) => self.item(path, rule, one, xy),
|
||||
(None, items) => {
|
||||
@@ -679,7 +712,7 @@ impl<'a> Writer<'a> {
|
||||
fn item(&mut self, path: &PathData, rule: FillRule, item: &AppearanceItem, xy: Xy) {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => self.fill_object(path, rule, f, xy),
|
||||
AppearanceItem::Stroke(s) if direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy),
|
||||
AppearanceItem::Stroke(s) if self.direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy),
|
||||
AppearanceItem::Stroke(s) => {
|
||||
self.warn(STROKES);
|
||||
if self.brush_art(path, s, xy) {
|
||||
@@ -716,15 +749,17 @@ impl<'a> Writer<'a> {
|
||||
true
|
||||
}
|
||||
|
||||
/// A fill as an object of its own, of transparency `xy`: a gradient as its average color, a
|
||||
/// pattern as its tiles cut to the path.
|
||||
/// A fill as an object of its own, of transparency `xy`: a color, a placed gradient or a
|
||||
/// pattern the data can define as the path's fill, another pattern as its tiles cut to the path.
|
||||
fn fill_object(&mut self, path: &PathData, rule: FillRule, f: &FillLayer, xy: Xy) {
|
||||
if !matches!(f.paint, Paint::None) && self.direct(&f.paint) {
|
||||
return self.path_object(path, rule, Some(f), None, xy);
|
||||
}
|
||||
match &f.paint {
|
||||
Paint::Solid { .. } => self.path_object(path, rule, Some(f), None, xy),
|
||||
Paint::Gradient(g) => {
|
||||
self.warn(GRADIENTS);
|
||||
let flat = FillLayer { paint: Paint::solid(average(&g.gradient)), ..f.clone() };
|
||||
self.path_object(path, rule, Some(&flat), None, xy);
|
||||
// A gradient not placed yet: placed on the path, or its average color.
|
||||
Paint::Solid { .. } | Paint::Gradient(_) => {
|
||||
let flat = self.placed(&f.paint, path.to_bezpath().bounding_box());
|
||||
self.path_object(path, rule, Some(&FillLayer { paint: flat, ..f.clone() }), None, xy);
|
||||
}
|
||||
Paint::Pattern { pattern, xf } => {
|
||||
self.warn(PATTERNS);
|
||||
@@ -826,11 +861,13 @@ impl<'a> Writer<'a> {
|
||||
if several {
|
||||
self.line("*u");
|
||||
}
|
||||
for sp in subpaths {
|
||||
// A gradient's instance paints the last subpath; the compound path takes its paint.
|
||||
let last = subpaths.len().saturating_sub(1);
|
||||
let outline = path.to_bezpath();
|
||||
for (i, sp) in subpaths.into_iter().enumerate() {
|
||||
ai_ops::subpath(&mut self.body, self.to_art, sp);
|
||||
let op = if sp.closed { op } else { op.to_ascii_uppercase() };
|
||||
self.body.push(op);
|
||||
self.body.push('\n');
|
||||
let gradients = if i == last { (gradient(fill.map(|f| &f.paint)), gradient(stroke.map(|s| &s.paint))) } else { (None, None) };
|
||||
self.painted(op, sp.closed, gradients, stroke.is_some(), false, &outline);
|
||||
}
|
||||
if several {
|
||||
self.line("*U");
|
||||
@@ -899,11 +936,15 @@ impl<'a> Writer<'a> {
|
||||
|
||||
// ---------- color ----------
|
||||
|
||||
/// Set the fill (or with `stroke`, the stroke) color to solid paint `p`: a spot or global
|
||||
/// swatch by its name, Registration, else the color itself.
|
||||
/// Set the fill (or with `stroke`, the stroke) to paint `p`: a spot or global swatch by its
|
||||
/// name, Registration, the color itself, or a pattern ([`Self::pattern_paint`]). A gradient sets
|
||||
/// nothing: its instance paints ([`Self::painted`]).
|
||||
fn color(&mut self, p: &Paint, stroke: bool) {
|
||||
let Paint::Solid { color, swatch, tint } = p else {
|
||||
return self.plain_color(Color::gray(1.0), stroke);
|
||||
let (color, swatch, tint) = match p {
|
||||
Paint::Solid { color, swatch, tint } => (color, swatch, tint),
|
||||
Paint::Pattern { pattern, xf } => return self.pattern_paint(pattern, *xf, stroke),
|
||||
Paint::Gradient(_) => return,
|
||||
Paint::None => return self.plain_color(Color::gray(1.0), stroke),
|
||||
};
|
||||
if swatch.as_deref() == Some(REGISTRATION) {
|
||||
let mut s = String::from("1 1 1 1 ");
|
||||
@@ -989,6 +1030,7 @@ impl<'a> Writer<'a> {
|
||||
/// (the ungrouped ones first, then each color group's after `0 (name) count Pg`), `PB`,
|
||||
/// `%AI5_EndPalette`.
|
||||
pub(super) fn palette(&mut self, s: &mut String) {
|
||||
self.in_palette = true;
|
||||
s.push_str("%AI5_BeginPalette\n0 0 Pb\n");
|
||||
self.registration_cell(s);
|
||||
let doc = self.doc;
|
||||
@@ -996,13 +1038,13 @@ impl<'a> Writer<'a> {
|
||||
self.swatch(s, sw);
|
||||
}
|
||||
for g in doc.swatch_groups.iter() {
|
||||
let colors: Vec<&Swatch> = g.swatches.iter().filter(|sw| writable(sw)).collect();
|
||||
let _ = writeln!(s, "0 {} {} Pg", ps::bytes(g.name.as_bytes()), colors.len());
|
||||
for sw in &g.swatches {
|
||||
self.swatch(s, sw);
|
||||
}
|
||||
let mut cells = String::new();
|
||||
let count = g.swatches.iter().filter(|sw| self.swatch(&mut cells, sw)).count();
|
||||
let _ = writeln!(s, "0 {} {count} Pg", ps::bytes(g.name.as_bytes()));
|
||||
s.push_str(&cells);
|
||||
}
|
||||
s.push_str("PB\n%AI5_EndPalette\n");
|
||||
self.in_palette = false;
|
||||
}
|
||||
|
||||
/// The Registration swatch, which every palette in the files users sent starts with: in a
|
||||
@@ -1021,24 +1063,31 @@ impl<'a> Writer<'a> {
|
||||
let _ = writeln!(s, "{name} Pc");
|
||||
}
|
||||
|
||||
/// One swatch of the palette: a color swatch's color and `(name) Pc`; gradient and pattern
|
||||
/// swatches are left out.
|
||||
fn swatch(&mut self, s: &mut String, sw: &Swatch) {
|
||||
/// One swatch of the palette: a color swatch's color, a gradient swatch's or pattern swatch's
|
||||
/// paint ([`Self::swatch_paint`]), then `(name) Pc`. False when it isn't written: Registration
|
||||
/// (written first), the None swatch, and paint the data can't hold (with a warning).
|
||||
fn swatch(&mut self, s: &mut String, sw: &Swatch) -> bool {
|
||||
if sw.name == REGISTRATION || matches!(sw.paint, Paint::None) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if !writable(sw) {
|
||||
return self.warn(SWATCHES);
|
||||
}
|
||||
let Paint::Solid { color, swatch, .. } = &sw.paint else { return };
|
||||
if swatch.is_none() && (sw.global || sw.spot) {
|
||||
self.named(s, &sw.name, *color, 1.0, sw.spot, false);
|
||||
} else {
|
||||
let body = std::mem::take(&mut self.body);
|
||||
self.plain_color(*color, false);
|
||||
s.push_str(&std::mem::replace(&mut self.body, body));
|
||||
match &sw.paint {
|
||||
Paint::Solid { color, swatch, .. } => {
|
||||
if swatch.is_none() && (sw.global || sw.spot) {
|
||||
self.named(s, &sw.name, *color, 1.0, sw.spot, false);
|
||||
} else {
|
||||
let body = std::mem::take(&mut self.body);
|
||||
self.plain_color(*color, false);
|
||||
s.push_str(&std::mem::replace(&mut self.body, body));
|
||||
}
|
||||
}
|
||||
_ if self.swatch_paint(s, sw) => {}
|
||||
_ => {
|
||||
self.warn(SWATCHES);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let _ = writeln!(s, "{} Pc", ps::bytes(sw.name.as_bytes()));
|
||||
true
|
||||
}
|
||||
|
||||
/// The spot color swatches, with their full colors' CMYK equivalents, for the header.
|
||||
@@ -1057,14 +1106,12 @@ impl<'a> Writer<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `sw` a color swatch the palette writes?
|
||||
fn writable(sw: &Swatch) -> bool {
|
||||
matches!(sw.paint, Paint::Solid { .. }) && sw.name != REGISTRATION
|
||||
}
|
||||
|
||||
/// Does a plain line of one color draw `s` (the line style operators can write it)?
|
||||
fn direct_stroke(s: &StrokeLayer) -> bool {
|
||||
matches!(s.paint, Paint::Solid { .. }) && stroke::is_plain(s)
|
||||
/// The gradient of paint `p`, when it has one.
|
||||
fn gradient(p: Option<&Paint>) -> Option<&vectorcraft_color::GradientPaint> {
|
||||
match p {
|
||||
Some(Paint::Gradient(g)) => Some(g),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `n`'s fills and strokes that paint.
|
||||
@@ -1079,19 +1126,6 @@ fn visible_items(n: &Node) -> Vec<&AppearanceItem> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `n`'s paint as one path object paints: no paint, a fill of a color, a plain line of a color,
|
||||
/// or both, the line over the fill; `None` when it has other paint.
|
||||
fn plain_paint(n: &Node) -> Option<(Option<&FillLayer>, Option<&StrokeLayer>)> {
|
||||
let solid = |p: &Paint| matches!(p, Paint::Solid { .. });
|
||||
match visible_items(n).as_slice() {
|
||||
[] => Some((None, None)),
|
||||
[AppearanceItem::Fill(f)] if solid(&f.paint) => Some((Some(f), None)),
|
||||
[AppearanceItem::Stroke(s)] if direct_stroke(s) => Some((None, Some(s))),
|
||||
[AppearanceItem::Fill(f), AppearanceItem::Stroke(s)] if solid(&f.paint) && direct_stroke(s) => Some((Some(f), Some(s))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The painting operator of a closed subpath (§5.3.3): `b` fill and stroke, `f` fill, `s`
|
||||
/// stroke, `n` neither; an open one's is upper case.
|
||||
fn paint_op(fill: bool, stroke: bool) -> char {
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
//! Gradients and patterns: their definitions in the setup, gradient instances on paths and the
|
||||
//! pattern operators (see [`crate::ai_editing`] for the form).
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use vectorcraft_color::gradient::GradientGeom;
|
||||
use vectorcraft_color::swatch::REGISTRATION;
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, FillLayer, PatternDef, StrokeLayer, TileType};
|
||||
use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, BezPath, Rect, Shape, Vec2};
|
||||
|
||||
use super::{MAX_NEST, Writer, Xy};
|
||||
use crate::ai_ops::{average, unit};
|
||||
use crate::ps;
|
||||
|
||||
pub(in crate::ai_editing) const GRADIENTS: &str =
|
||||
"freeform gradients, and gradients along or across strokes, are written as their average color in the editing data for other apps";
|
||||
pub(in crate::ai_editing) const GRADIENT_SHAPES: &str = "gradients without stops or with more than 256, gradients whose ramp has no length and radial gradients flattened to a line are written as their average color in the editing data for other apps";
|
||||
pub(in crate::ai_editing) const SWATCH_PLACEMENT: &str =
|
||||
"the placement of pattern swatches that move, turn or scale their pattern is left out of the editing data for other apps";
|
||||
pub(in crate::ai_editing) const GRADIENT_MIX: &str =
|
||||
"gradients that mix their colors perceptually or dither are written as classic gradients in the editing data for other apps";
|
||||
pub(in crate::ai_editing) const STOP_LINKS: &str =
|
||||
"gradient stops linked to global process colors are written as their colors in the editing data for other apps";
|
||||
/// The length of a linear gradient's caps (`Bc`): the canvas's side.
|
||||
const CAP: f64 = super::super::CANVAS_SIZE as f64;
|
||||
|
||||
/// A gradient definition: its name, its gradient, whether it is a gradient swatch's (named after
|
||||
/// the swatch) and whether the art uses it (else only the palette does).
|
||||
pub(super) struct GradientDef {
|
||||
name: String,
|
||||
gradient: Gradient,
|
||||
swatch: bool,
|
||||
used: bool,
|
||||
}
|
||||
|
||||
/// A pattern definition written: its name, its text (`%AI3_BeginPattern` to `%AI3_EndPattern`)
|
||||
/// and whether the art uses it.
|
||||
pub(super) struct PatternOut {
|
||||
name: String,
|
||||
text: String,
|
||||
used: bool,
|
||||
}
|
||||
|
||||
/// Can `g` be written as a gradient (a linear or radial one with stops, no more than the `.ai`
|
||||
/// reader reads)?
|
||||
fn writable_gradient(g: &Gradient) -> bool {
|
||||
g.kind != GradientKind::Freeform && !g.stops.is_empty() && g.stops.len() <= crate::import::MAX_GRADIENT_STOPS
|
||||
}
|
||||
|
||||
/// The stops of `g` in order of their place on the ramp.
|
||||
fn sorted_stops(g: &Gradient) -> Vec<&GradientStop> {
|
||||
let mut stops: Vec<&GradientStop> = g.stops.iter().collect();
|
||||
stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
stops
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
/// Each gradient swatch's definition, named after the swatch, before the art is written.
|
||||
pub(in crate::ai_editing) fn reserve_swatch_gradients(&mut self) {
|
||||
let doc = self.doc;
|
||||
for sw in doc.swatches_iter() {
|
||||
if let Paint::Gradient(g) = &sw.paint
|
||||
&& writable_gradient(&g.gradient)
|
||||
&& !self.gradients.iter().any(|d| d.name == sw.name)
|
||||
{
|
||||
self.gradients.push(GradientDef { name: sw.name.clone(), gradient: g.gradient.clone(), swatch: true, used: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the definition of `g`: the swatch `swatch` names when it is a gradient swatch
|
||||
/// of the same gradient, else a definition of the same gradient that no swatch names, else a
|
||||
/// new one (`Gradient`, numbered when taken by another definition, a swatch or a pattern).
|
||||
fn define_gradient(&mut self, g: &Gradient, swatch: Option<&str>) -> String {
|
||||
let used = !self.in_palette;
|
||||
let found = swatch
|
||||
.and_then(|s| self.gradients.iter().position(|d| d.swatch && d.name == s && d.gradient == *g))
|
||||
.or_else(|| self.gradients.iter().position(|d| !d.swatch && d.gradient == *g));
|
||||
if let Some(d) = found.and_then(|i| self.gradients.get_mut(i)) {
|
||||
d.used |= used;
|
||||
return d.name.clone();
|
||||
}
|
||||
let doc = self.doc;
|
||||
let taken = |gradients: &[GradientDef], name: &str| {
|
||||
gradients.iter().any(|d| d.name == name) || doc.swatch(name).is_some() || doc.pattern(name).is_some()
|
||||
};
|
||||
let mut name = String::from("Gradient");
|
||||
let mut i = 1;
|
||||
while taken(&self.gradients, &name) {
|
||||
i += 1;
|
||||
name = format!("Gradient {i}");
|
||||
}
|
||||
self.gradients.push(GradientDef { name: name.clone(), gradient: g.clone(), swatch: false, used });
|
||||
name
|
||||
}
|
||||
|
||||
/// `p` as the paint of a fill or stroke whose gradient is placed on `bounds`: a gradient is
|
||||
/// placed there (it then no longer depends on the shape it paints, which may be cut); one the
|
||||
/// data can't hold is its average color, with a warning.
|
||||
pub(super) fn placed(&mut self, p: &Paint, bounds: Rect) -> Paint {
|
||||
let Paint::Gradient(g) = p else { return p.clone() };
|
||||
let geom = g.resolve(bounds);
|
||||
let finite = [geom.start.x, geom.start.y, geom.end.x, geom.end.y, geom.aspect].iter().all(|v| v.is_finite());
|
||||
let radial = g.gradient.kind == GradientKind::Radial;
|
||||
if g.gradient.kind == GradientKind::Freeform {
|
||||
self.warn(GRADIENTS);
|
||||
return Paint::solid(average(&g.gradient));
|
||||
}
|
||||
if !writable_gradient(&g.gradient) || !finite || geom.length() < 1e-9 || (radial && geom.aspect <= 1e-9) {
|
||||
self.warn(GRADIENT_SHAPES);
|
||||
return Paint::solid(average(&g.gradient));
|
||||
}
|
||||
Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..(**g).clone() }))
|
||||
}
|
||||
|
||||
/// The fills and strokes of `n` that paint (outline `bounds`), their gradients placed
|
||||
/// ([`Self::placed`]): a fill's on the shape's bounds, a stroke's on what it paints. A gradient
|
||||
/// along or across a stroke is its average color (the stroke is written as its outline).
|
||||
pub(super) fn items(&mut self, n: &vectorcraft_doc::Node, bounds: Rect) -> Vec<AppearanceItem> {
|
||||
super::visible_items(n)
|
||||
.into_iter()
|
||||
.map(|i| match i {
|
||||
AppearanceItem::Fill(f) => AppearanceItem::Fill(FillLayer { paint: self.placed(&f.paint, bounds), ..f.clone() }),
|
||||
AppearanceItem::Stroke(s) => {
|
||||
let paint = match s.path_gradient() {
|
||||
Some(g) => {
|
||||
self.warn(GRADIENTS);
|
||||
Paint::solid(average(&g.gradient))
|
||||
}
|
||||
None => self.placed(&s.paint, s.paint_bounds(bounds)),
|
||||
};
|
||||
AppearanceItem::Stroke(StrokeLayer { paint, ..s.clone() })
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Can paint `p` be written as the paint of a path object: a color, a placed gradient, or a
|
||||
/// pattern [`Self::pattern_writable`]?
|
||||
pub(super) fn direct(&self, p: &Paint) -> bool {
|
||||
match p {
|
||||
Paint::Solid { .. } => true,
|
||||
Paint::Gradient(g) => g.geom.is_some() && writable_gradient(&g.gradient),
|
||||
Paint::Pattern { pattern, .. } => self.pattern_writable(pattern),
|
||||
Paint::None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a plain line of `s`'s paint draw it ([`Self::direct`], and the line style operators can
|
||||
/// write it)?
|
||||
pub(super) fn direct_stroke(&self, s: &StrokeLayer) -> bool {
|
||||
self.direct(&s.paint) && stroke::is_plain(s)
|
||||
}
|
||||
|
||||
/// `n`'s paint `items` as one path object paints: no paint, a fill, a plain line, or both, the
|
||||
/// line over the fill; `None` when it has other paint.
|
||||
pub(super) fn plain_paint<'i>(&self, items: &'i [AppearanceItem]) -> Option<(Option<&'i FillLayer>, Option<&'i StrokeLayer>)> {
|
||||
match items {
|
||||
[] => Some((None, None)),
|
||||
[AppearanceItem::Fill(f)] if self.direct(&f.paint) => Some((Some(f), None)),
|
||||
[AppearanceItem::Stroke(s)] if self.direct_stroke(s) => Some((None, Some(s))),
|
||||
[AppearanceItem::Fill(f), AppearanceItem::Stroke(s)] if self.direct(&f.paint) && self.direct_stroke(s) => Some((Some(f), Some(s))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The painting operator `op` of a subpath (`closed` or open), with the gradient instances of
|
||||
/// the fill (`fill`) and the stroke (`stroke`) around it when it is the last subpath of its
|
||||
/// object: `Bb` … `op` `flag BB`, the stroke's `1 Bb` … after the fill's `BB`; the flag is 0, or
|
||||
/// with a stroke 2 (closed) or 1 (open). `stroked`: the object has a stroke. A clipping path's
|
||||
/// instances (`clip`) don't issue a clip of their own. `path`: the path the operator paints (all
|
||||
/// its subpaths), in the document.
|
||||
pub(super) fn painted(
|
||||
&mut self,
|
||||
op: char,
|
||||
closed: bool,
|
||||
gradients: (Option<&GradientPaint>, Option<&GradientPaint>),
|
||||
stroked: bool,
|
||||
clip: bool,
|
||||
path: &BezPath,
|
||||
) {
|
||||
let op = if closed { op } else { op.to_ascii_uppercase() };
|
||||
let flag = match (stroked, closed) {
|
||||
(false, _) => "0 BB",
|
||||
(true, true) => "2 BB",
|
||||
(true, false) => "1 BB",
|
||||
};
|
||||
let (fill, stroke) = gradients;
|
||||
if let Some(f) = fill {
|
||||
self.instance(f, false, clip, path);
|
||||
if stroke.is_some() {
|
||||
self.line(if closed { "2 BB" } else { "1 BB" });
|
||||
}
|
||||
}
|
||||
if let Some(s) = stroke {
|
||||
self.instance(s, true, clip, path);
|
||||
}
|
||||
self.body.push(op);
|
||||
self.body.push('\n');
|
||||
if fill.is_some() || stroke.is_some() {
|
||||
self.line(flag);
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient instance's start (placed gradient `g`, on the stroke when `stroke`, on `path` in
|
||||
/// the document), before its painting operator: `Bb` (`1 Bb` on the stroke); a radial
|
||||
/// gradient's focal point `x y angle fraction Bh` (its offset from the center in the space of the
|
||||
/// gradient's matrices, then its angle and fraction as [`placement`] gives them; `0 0 0 0 Bh` at
|
||||
/// the center); `flag (name) x y angle length a b c d tx ty 1 Bg` (flag 1, or 0 for a clipping
|
||||
/// path; the rest as [`placement`] gives them); then the gradient's place: a linear one's `a b c d tx ty Xm` (the
|
||||
/// unit vector onto its whole ramp), its cap before the first stop `… Bc`, a `… Bm` for each pair
|
||||
/// of stops next to each other (the unit vector onto the part between them) and its cap past the
|
||||
/// last stop; a radial one's `… Bm` (the unit circle onto its extent ellipse).
|
||||
fn instance(&mut self, g: &GradientPaint, stroke: bool, clip: bool, path: &BezPath) {
|
||||
let Some(geom) = g.geom else { return };
|
||||
let name = self.define_gradient(&g.gradient, g.swatch.as_deref());
|
||||
self.line(if stroke { "1 Bb" } else { "Bb" });
|
||||
let m = self.grad_space;
|
||||
let u = geom.end - geom.start;
|
||||
let radial = g.gradient.kind == GradientKind::Radial;
|
||||
let place = placement(&geom, radial, &(self.canvas * path.clone()), self.canvas);
|
||||
if radial {
|
||||
let c = m * geom.start;
|
||||
let off = geom.focal.map_or(Vec2::ZERO, |f| m * f - c);
|
||||
let (angle, fraction) = if off.hypot() > 1e-9 { place.focal } else { (0.0, 0.0) };
|
||||
let off = if fraction > 0.0 { off } else { Vec2::ZERO };
|
||||
ps::push_nums(&mut self.body, &[off.x, off.y, angle, fraction]);
|
||||
self.line("Bh");
|
||||
}
|
||||
let _ = write!(self.body, "{} {} ", u8::from(!clip), ps::bytes(name.as_bytes()));
|
||||
ps::push_nums(&mut self.body, &place.bg);
|
||||
ps::push_nums(&mut self.body, &place.transform.as_coeffs());
|
||||
self.line("1 Bg");
|
||||
// The unit vector along the gradient, and across it as long.
|
||||
let frame = |from: f64, to: f64| {
|
||||
let p = geom.start + u * from;
|
||||
m * Affine::new([u.x * (to - from), u.y * (to - from), -u.y, u.x, p.x, p.y])
|
||||
};
|
||||
let mut matrix = |a: Affine, op: &str| {
|
||||
ps::push_nums(&mut self.body, &a.as_coeffs());
|
||||
self.body.push_str(op);
|
||||
self.body.push('\n');
|
||||
};
|
||||
if radial {
|
||||
matrix(m * geom.unit_frame().unwrap_or(Affine::new([u.x, u.y, -u.y, u.x, geom.start.x, geom.start.y])), "Bm");
|
||||
return;
|
||||
}
|
||||
matrix(frame(0.0, 1.0), "Xm");
|
||||
let offsets: Vec<f64> = sorted_stops(&g.gradient).iter().map(|s| f64::from(s.offset)).collect();
|
||||
let len = u.hypot().max(1e-9);
|
||||
let (first, last) = (offsets.first().copied().unwrap_or(0.0), offsets.last().copied().unwrap_or(1.0));
|
||||
matrix(frame(first - CAP / len, first), "Bc");
|
||||
for pair in offsets.windows(2) {
|
||||
if let [a, b] = pair {
|
||||
matrix(frame(*a, *b), "Bm");
|
||||
}
|
||||
}
|
||||
matrix(frame(last, last + CAP / len), "Bc");
|
||||
}
|
||||
|
||||
/// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd` (type 0 linear,
|
||||
/// 1 radial), `[`, each stop on a `%_` line (`color style opacity 6 midpoint rampPoint Bs`), a
|
||||
/// linear gradient's from the last to the first and a radial one's from the first to the last,
|
||||
/// `BD`, `%AI5_EndGradient`.
|
||||
fn gradient_def(&mut self, s: &mut String, d: &GradientDef) {
|
||||
if d.gradient.dither || !d.gradient.method.is_classic() {
|
||||
self.warn(GRADIENT_MIX);
|
||||
}
|
||||
let name = ps::bytes(d.name.as_bytes());
|
||||
let radial = d.gradient.kind == GradientKind::Radial;
|
||||
let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {} {} Bd\n[", u8::from(radial), d.gradient.stops.len());
|
||||
let mut stops = sorted_stops(&d.gradient);
|
||||
if !radial {
|
||||
stops.reverse();
|
||||
}
|
||||
for stop in stops {
|
||||
s.push_str("%_");
|
||||
self.stop_color(s, stop);
|
||||
ps::push_nums(s, &[f64::from(unit(stop.opacity)), 6.0, f64::from(stop.midpoint) * 100.0, f64::from(stop.offset) * 100.0]);
|
||||
s.push_str("Bs\n");
|
||||
}
|
||||
s.push_str("BD\n%AI5_EndGradient\n");
|
||||
}
|
||||
|
||||
/// A stop's color and its style: gray `lightness 0`, CMYK `c m y k 1`, RGB (and Lab, as RGB)
|
||||
/// `c m y k r g b 2` with its CMYK equivalent; a spot color its full color's CMYK equivalent,
|
||||
/// then its RGB values when it isn't CMYK, its name and how much lighter than the full color,
|
||||
/// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB).
|
||||
fn stop_color(&mut self, s: &mut String, stop: &GradientStop) {
|
||||
let doc = self.doc;
|
||||
let intent = self.cms.settings().intent;
|
||||
if let Some(name) = stop.swatch.as_deref().filter(|n| *n != REGISTRATION)
|
||||
&& let Some(sw) = doc.swatch(name)
|
||||
&& let Paint::Solid { color: full, swatch: None, .. } = sw.paint
|
||||
{
|
||||
if sw.spot {
|
||||
let cmyk = self.cms.to_cmyk(&doc.linked_color(full, true), intent).map(unit);
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let rgb = !matches!(full, Color::Cmyk { .. });
|
||||
if rgb {
|
||||
ps::push_nums(s, &full.to_rgb().map(|v| f64::from(unit(v))));
|
||||
}
|
||||
s.push_str(&ps::bytes(name.as_bytes()));
|
||||
s.push(' ');
|
||||
ps::push_num(s, f64::from(1.0 - unit(stop.tint)));
|
||||
s.push_str(if rgb { " 1 4 " } else { " 3 " });
|
||||
return;
|
||||
}
|
||||
if sw.global {
|
||||
self.warn(STOP_LINKS);
|
||||
}
|
||||
}
|
||||
match stop.color {
|
||||
Color::Gray { k } => {
|
||||
ps::push_num(s, f64::from(1.0 - unit(k)));
|
||||
s.push_str(" 0 ");
|
||||
}
|
||||
Color::Cmyk { c, m, y, k } => {
|
||||
ps::push_nums(s, &[c, m, y, k].map(|v| f64::from(unit(v))));
|
||||
s.push_str("1 ");
|
||||
}
|
||||
color @ (Color::Rgb { .. } | Color::Lab { .. }) => {
|
||||
let cmyk = self.cms.to_cmyk(&color, intent).map(unit);
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v))));
|
||||
s.push_str("2 ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- patterns ----------
|
||||
|
||||
/// Can pattern `name` be written as a definition and painted with from here: the document has
|
||||
/// it, its tiles make a grid, its art is within its tile and holds something, and it isn't used
|
||||
/// in another pattern's tile or nested more than [`MAX_NEST`] deep?
|
||||
pub(super) fn pattern_writable(&self, name: &str) -> bool {
|
||||
if !self.defining.is_empty() || self.nest >= MAX_NEST {
|
||||
return false;
|
||||
}
|
||||
if self.patterns.iter().any(|p| p.name == name) {
|
||||
return true;
|
||||
}
|
||||
self.doc.pattern(name).is_some_and(tile_holds_art)
|
||||
}
|
||||
|
||||
/// Write pattern `name`'s definition, once: `%AI3_BeginPattern: (name)`, `(name) llx lly urx
|
||||
/// ury` (the tile, y up), the tile's box (an unpainted path whose dictionary marks it,
|
||||
/// `AIPattern_Editor_Backing_Tile_Rect`) and the art on `%_` lines, `E`, `%AI3_EndPattern`.
|
||||
/// False when it can't be ([`Self::pattern_writable`]).
|
||||
fn define_pattern(&mut self, name: &str) -> bool {
|
||||
if !self.pattern_writable(name) {
|
||||
return false;
|
||||
}
|
||||
let used = !self.in_palette;
|
||||
if let Some(p) = self.patterns.iter_mut().find(|p| p.name == name) {
|
||||
p.used |= used;
|
||||
return true;
|
||||
}
|
||||
let doc = self.doc;
|
||||
let Some(def) = doc.pattern(name) else { return false };
|
||||
self.defining.push(name.to_string());
|
||||
// The art is written in the pattern's space, y up, from the state a definition starts in.
|
||||
let saved = (
|
||||
std::mem::take(&mut self.body),
|
||||
(self.to_art, self.grad_space, self.canvas),
|
||||
self.state.take(),
|
||||
self.gs.replace(Xy::OPAQUE),
|
||||
self.clip.take(),
|
||||
self.block_end,
|
||||
);
|
||||
// `Bg` places a gradient in a pattern's tile as on the canvas, mirrored top to bottom: its
|
||||
// placement is worked out in the tile's space turned y up.
|
||||
(self.to_art, self.grad_space, self.canvas, self.block_end) = (Affine::FLIP_Y, self.tile_grad_space, Affine::FLIP_Y, usize::MAX);
|
||||
// The tile's box first, unpainted and marked as the box: the tile is that box.
|
||||
self.state(false, false);
|
||||
self.line("0 XR");
|
||||
if let Some(sp) = vectorcraft_geom::shapes::rectangle(def.tile).subpaths.first() {
|
||||
crate::ai_ops::subpath(&mut self.body, self.to_art, sp);
|
||||
}
|
||||
self.line("n\n%_/ArtDictionary :\n%_1 /Bool (AIPattern_Editor_Backing_Tile_Rect) ,\n%_;\n%_");
|
||||
self.nest += 1;
|
||||
for a in &def.art {
|
||||
self.node(a);
|
||||
}
|
||||
self.nest -= 1;
|
||||
let art;
|
||||
(art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, self.clip, self.block_end) = saved;
|
||||
let art = std::mem::replace(&mut self.body, art);
|
||||
self.defining.pop();
|
||||
let tile = Affine::FLIP_Y.transform_rect_bbox(def.tile);
|
||||
let quoted = ps::bytes(name.as_bytes());
|
||||
let mut text = format!("%AI3_BeginPattern: {quoted}\n{quoted} ");
|
||||
ps::push_nums(&mut text, &[tile.x0, tile.y0, tile.x1, tile.y1]);
|
||||
text.pop();
|
||||
text.push('\n');
|
||||
for l in art.lines() {
|
||||
if !l.starts_with("%_") {
|
||||
text.push_str("%_");
|
||||
}
|
||||
text.push_str(l);
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str("E\n%AI3_EndPattern\n");
|
||||
self.patterns.push(PatternOut { name: name.to_string(), text, used });
|
||||
true
|
||||
}
|
||||
|
||||
/// Set the fill (or with `stroke`, the stroke) to pattern `name` placed by `xf` (the pattern's
|
||||
/// space → the document): `(name) 0 0 1 1 0 0 0 0 0 [matrix] p` (`P` for the stroke), the
|
||||
/// matrix taking the pattern's space (y up, the tile's lower left corner its origin) to the
|
||||
/// art's. A pattern that can't be defined paints mid gray.
|
||||
pub(super) fn pattern_paint(&mut self, name: &str, xf: Affine, stroke: bool) {
|
||||
let height = self.doc.pattern(name).map(|d| d.tile.height());
|
||||
let Some(h) = height.filter(|_| self.define_pattern(name)) else {
|
||||
self.warn(super::PATTERNS);
|
||||
return self.plain_color(Color::gray(0.5), stroke);
|
||||
};
|
||||
let m = self.to_art * xf * Affine::translate((0.0, h)) * Affine::FLIP_Y;
|
||||
let _ = writeln!(self.body, "{} 0 0 1 1 0 0 0 0 0 {} {}", ps::bytes(name.as_bytes()), ps::matrix(m), if stroke { 'P' } else { 'p' });
|
||||
}
|
||||
|
||||
/// The definitions, in the setup before the palette: the gradients the art uses (`n Bn`, then
|
||||
/// each), those only the palette uses between `%AI5_Begin_NonPrinting`, `Np` and
|
||||
/// `%AI5_End_NonPrinting--`, then the patterns in the same way (a pattern after those its art
|
||||
/// uses).
|
||||
pub(in crate::ai_editing) fn definitions(&mut self, s: &mut String) {
|
||||
let defs = std::mem::take(&mut self.gradients);
|
||||
let (used, unused): (Vec<&GradientDef>, Vec<&GradientDef>) = defs.iter().partition(|d| d.used);
|
||||
let _ = writeln!(s, "{} Bn", used.len());
|
||||
for d in &used {
|
||||
self.gradient_def(s, d);
|
||||
}
|
||||
if !unused.is_empty() {
|
||||
let _ = writeln!(s, "%AI5_Begin_NonPrinting\nNp\n{} Bn", unused.len());
|
||||
for d in &unused {
|
||||
self.gradient_def(s, d);
|
||||
}
|
||||
s.push_str("%AI5_End_NonPrinting--\n");
|
||||
}
|
||||
self.gradients = defs;
|
||||
for p in self.patterns.iter().filter(|p| p.used) {
|
||||
s.push_str(&p.text);
|
||||
}
|
||||
if self.patterns.iter().any(|p| !p.used) {
|
||||
s.push_str("%AI5_Begin_NonPrinting\nNp\n");
|
||||
for p in self.patterns.iter().filter(|p| !p.used) {
|
||||
s.push_str(&p.text);
|
||||
}
|
||||
s.push_str("%AI5_End_NonPrinting--\n");
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient swatch's or pattern swatch's paint in the palette: `Bb`, a radial gradient's
|
||||
/// `0 0 0 0 Bh`, `2 (name) 0 0 0 1 1 0 0 1 0 0 1 Bg`, `0 BB`; a pattern's
|
||||
/// `(name) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p`. False for other paint, and for paint that can't
|
||||
/// be written.
|
||||
pub(super) fn swatch_paint(&mut self, s: &mut String, sw: &vectorcraft_color::swatch::Swatch) -> bool {
|
||||
match &sw.paint {
|
||||
Paint::Gradient(g) if writable_gradient(&g.gradient) => {
|
||||
let swatch = Some(sw.name.as_str());
|
||||
let name = self.define_gradient(&g.gradient, swatch);
|
||||
s.push_str("Bb\n");
|
||||
if g.gradient.kind == GradientKind::Radial {
|
||||
s.push_str("0 0 0 0 Bh\n");
|
||||
}
|
||||
let _ = writeln!(s, "2 {} 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB", ps::bytes(name.as_bytes()));
|
||||
true
|
||||
}
|
||||
Paint::Pattern { pattern, xf } if self.define_pattern(pattern) => {
|
||||
if *xf != Affine::IDENTITY {
|
||||
self.warn(SWATCH_PLACEMENT);
|
||||
}
|
||||
let _ = writeln!(s, "{} 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", ps::bytes(pattern.as_bytes()));
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `Bg` places a gradient: its `x y angle length` operands, its matrix, and a radial
|
||||
/// gradient's focal point as `Bh`'s angle and fraction ([`placement`]).
|
||||
struct Placement {
|
||||
bg: [f64; 4],
|
||||
transform: Affine,
|
||||
focal: (f64, f64),
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
/// The gradient's place on the path before it is moved: `0 0 0 1`, the identity.
|
||||
const UNMOVED: Placement = Placement { bg: [0.0, 0.0, 0.0, 1.0], transform: Affine::IDENTITY, focal: (0.0, 0.0) };
|
||||
}
|
||||
|
||||
/// The `Bg` operands that place gradient `geom` (`radial` or linear) on `path`, both in the canvas's
|
||||
/// space (y down; `canvas` takes the document there; in a pattern's tile, the tile's space turned y
|
||||
/// up). `Bg` places a gradient in the space its matrix takes to the canvas's, on the path as it lies
|
||||
/// there:
|
||||
///
|
||||
/// - a linear one's ramp runs at `angle` degrees (counterclockwise, y up) from `x` times the path's
|
||||
/// extent along that angle past where the path starts along it, `length` times that extent long
|
||||
/// (`y` doesn't move it);
|
||||
/// - a radial one's center lies `x` times the width and `y` times the height (y down) of the path's
|
||||
/// bounds from their center, and its radius is `length` times √((dx² + dy²) / 2), `dx` and `dy` the
|
||||
/// center's distances to the right side and to the bottom of the bounds, whether or not those are
|
||||
/// the nearer sides; its focal point lies at `Bh`'s angle (y up), its distance from the center a
|
||||
/// fraction of the radius.
|
||||
///
|
||||
/// A linear gradient is written with the identity, a radial one with the matrix that takes the
|
||||
/// unit circle onto its extent ellipse at the origin (so that its circle there is the unit circle).
|
||||
/// [`Placement::UNMOVED`] when the path has no extent along the gradient or the gradient has none.
|
||||
fn placement(geom: &GradientGeom, radial: bool, path: &BezPath, canvas: Affine) -> Placement {
|
||||
let s = canvas * geom.start;
|
||||
let u = canvas * geom.end - s;
|
||||
if !radial {
|
||||
let turn = Affine::rotate(-u.atan2());
|
||||
let along = (turn * path.clone()).bounding_box();
|
||||
let extent = along.width();
|
||||
if !(extent > 1e-9 && extent.is_finite() && u.hypot() > 1e-9) {
|
||||
return Placement::UNMOVED;
|
||||
}
|
||||
let bg = [((turn * s).x - along.x0) / extent, 0.0, (-u.y).atan2(u.x).to_degrees(), u.hypot() / extent];
|
||||
return Placement { bg, ..Placement::UNMOVED };
|
||||
}
|
||||
// The unit circle's image: `u` along the vector, `v` across it (the aspect ratio), turning the same way.
|
||||
let across = geom.end - geom.start;
|
||||
let mut v = canvas * (geom.start + Vec2::new(-across.y, across.x) * geom.aspect) - s;
|
||||
if u.cross(v) < 0.0 {
|
||||
v = -v;
|
||||
}
|
||||
let m = Affine::new([u.x, u.y, v.x, v.y, 0.0, 0.0]);
|
||||
let det = m.determinant().abs();
|
||||
if det.is_nan() || det <= 1e-12 {
|
||||
return Placement::UNMOVED;
|
||||
}
|
||||
let back = m.inverse();
|
||||
let b = (back * path.clone()).bounding_box();
|
||||
let c = back * s;
|
||||
let (dx, dy) = (b.x1 - c.x, b.y1 - c.y);
|
||||
let unit = ((dx * dx + dy * dy) / 2.0).sqrt();
|
||||
if !(b.width() > 1e-9 && b.height() > 1e-9 && unit > 1e-9 && unit.is_finite()) {
|
||||
return Placement::UNMOVED;
|
||||
}
|
||||
let off = geom.focal.map_or(Vec2::ZERO, |f| back * (canvas * f) - c);
|
||||
let focal = if off.hypot() > 1e-9 { ((-off.y).atan2(off.x).to_degrees(), off.hypot()) } else { (0.0, 0.0) };
|
||||
let bg = [(c.x - b.center().x) / b.width(), (c.y - b.center().y) / b.height(), 0.0, 1.0 / unit];
|
||||
Placement { bg, transform: m, focal }
|
||||
}
|
||||
|
||||
/// Do `def`'s tiles make a grid, with its art (something) within the tile? Art past the tile would
|
||||
/// be cut off at the tile's edge.
|
||||
fn tile_holds_art(def: &PatternDef) -> bool {
|
||||
let t = def.tile;
|
||||
let tile_ok = [t.x0, t.y0, t.x1, t.y1].iter().all(|v| v.is_finite()) && t.width() > 0.0 && t.height() > 0.0;
|
||||
tile_ok
|
||||
&& def.tile_type == TileType::Grid
|
||||
&& def.art_bounds().is_some_and(|b| {
|
||||
let room = t.inflate(1e-6, 1e-6);
|
||||
b.x0 >= room.x0 && b.y0 >= room.y0 && b.x1 <= room.x1 && b.y1 <= room.y1
|
||||
})
|
||||
}
|
||||
@@ -70,6 +70,7 @@ use graphics::{GState, Out};
|
||||
use interp::{Fault, Interp};
|
||||
use obj::PsError;
|
||||
|
||||
pub(crate) use ai::MAX_GRADIENT_STOPS;
|
||||
pub(crate) use native::scale_document;
|
||||
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, is_loss, layered_ai, layered_ai_from};
|
||||
pub use text::family_style;
|
||||
|
||||
@@ -36,6 +36,7 @@ use crate::ps;
|
||||
|
||||
pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN;
|
||||
pub(crate) use frames::text_frames;
|
||||
pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS;
|
||||
pub use read::{
|
||||
BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, bindings_note, brushes, palette, read, read_beside, variables,
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ use vectorcraft_color::{Color, Paint};
|
||||
use super::obj::V;
|
||||
|
||||
/// Largest number of stops read in one gradient.
|
||||
const MAX_STOPS: usize = 256;
|
||||
pub(crate) const MAX_STOPS: usize = 256;
|
||||
|
||||
/// A colour read with the swatch it names (spot and global colours).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -172,7 +172,16 @@ impl GradientDef {
|
||||
}
|
||||
// Stops are listed along the ramp from either end: listed downwards they're reversed, so
|
||||
// stops sharing a ramp point keep their order (and each its midpoint, to the next stop).
|
||||
if self.gradient.stops.first().zip(self.gradient.stops.last()).is_some_and(|(first, last)| first.offset > last.offset) {
|
||||
// When every stop shares one ramp point, a linear gradient's are taken as listed from the
|
||||
// last, as newer files list a linear gradient's stops.
|
||||
let linear = self.gradient.kind == GradientKind::Linear;
|
||||
if self
|
||||
.gradient
|
||||
.stops
|
||||
.first()
|
||||
.zip(self.gradient.stops.last())
|
||||
.is_some_and(|(first, last)| first.offset > last.offset || (linear && self.gradient.stops.len() > 1 && first.offset == last.offset))
|
||||
{
|
||||
self.gradient.stops.reverse();
|
||||
}
|
||||
self.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
@@ -434,6 +443,24 @@ mod tests {
|
||||
assert_eq!(g.stops.len(), MAX_STOPS);
|
||||
}
|
||||
|
||||
/// A hard edge: every stop at one ramp point. A linear gradient's are listed from the last, a
|
||||
/// radial one's from the first.
|
||||
#[test]
|
||||
fn stops_at_one_ramp_point_keep_their_sides() {
|
||||
for (kind, listed) in [(0.0, [0.75, 0.25]), (1.0, [0.25, 0.75])] {
|
||||
let mut d = GradientDef::begin(&[V::Str(b"E".to_vec()), V::Num(kind), V::Num(2.0)]).unwrap();
|
||||
for (gray, mid) in listed.into_iter().zip([40.0, 70.0]) {
|
||||
d.stop(&nums(&[gray, 0.0, 1.0, 6.0, mid, 30.0]), &mut vec![]);
|
||||
}
|
||||
let (_, g) = d.finish().unwrap();
|
||||
let lightness: Vec<f32> = g.stops.iter().map(|s| (s.color.to_rgb()[0] * 100.0).round() / 100.0).collect();
|
||||
let first_mid = if kind == 0.0 { 0.7 } else { 0.4 };
|
||||
assert_eq!(lightness, vec![0.25, 0.75], "kind {kind}");
|
||||
assert!((g.stops[0].midpoint - first_mid).abs() < 1e-6, "kind {kind}: {:?}", g.stops);
|
||||
assert!(g.stops.iter().all(|s| (s.offset - 0.3).abs() < 1e-6));
|
||||
}
|
||||
}
|
||||
|
||||
/// A linear gradient of three stops as newer files place it: `Bg` with numbers that aren't in
|
||||
/// art space, `Xm` for the whole ramp and a `Bm` for each pair of stops.
|
||||
#[test]
|
||||
|
||||
@@ -52,7 +52,9 @@
|
||||
//! lines and `1 (style) XW`: the drawn look is kept, named after the object.
|
||||
//! - Patterns: `%AI3_BeginPattern: (name)`, `(name) llx lly urx ury` (the tile, in the pattern's
|
||||
//! space, y up), the tile's art (on `%_` lines in newer files), `E`, `%AI3_EndPattern`; each is
|
||||
//! read by a reader of its own and becomes a pattern swatch. In the version 7 form (§3.4) a list
|
||||
//! read by a reader of its own and becomes a pattern swatch. An unpainted path whose dictionary
|
||||
//! marks it as the tile's box (`AIPattern_Editor_Backing_Tile_Rect`) is left out of the art. In
|
||||
//! the version 7 form (§3.4) a list
|
||||
//! of layers follows the tile, `[ (colors) @ (tile) & … ] E`, each layer's tile read from its
|
||||
//! string with the colors' state (`_ &`: the tile's box filled). A fill or stroke with one:
|
||||
//! `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`), the matrix
|
||||
|
||||
@@ -495,9 +495,20 @@ fn text_wrap(o: &Obj, scale: f64) -> Option<TextWrap> {
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
/// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the
|
||||
/// opacity mask it may carry.
|
||||
/// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), the opacity
|
||||
/// mask it may carry, and in a pattern's definition the mark of the tile's box
|
||||
/// (`AIPattern_Editor_Backing_Tile_Rect`), which leaves that box out of the art.
|
||||
pub(super) fn art_dictionary(&mut self, o: &Obj) {
|
||||
// The unpainted box that a pattern's definition marks as its tile's box isn't art.
|
||||
if self.pattern.is_some()
|
||||
&& o.nums("AIPattern_Editor_Backing_Tile_Rect").first().copied() == Some(1.0)
|
||||
&& self.after_object
|
||||
&& let Some(f) = self.frames.last_mut()
|
||||
&& f.children.last().is_some_and(|n| matches!(n.kind, NodeKind::Path { .. }) && n.appearance.items.iter().all(|i| i.paint().is_none()))
|
||||
{
|
||||
f.children.pop();
|
||||
return;
|
||||
}
|
||||
self.constraint_art(o);
|
||||
// A blend's key object or spine.
|
||||
if let Some(kind) = o.text("ShapeBlendPathType")
|
||||
|
||||
@@ -454,6 +454,46 @@ fn a_gradient_stop_naming_a_process_colour_leaves_its_swatch_process() {
|
||||
const DOTS: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 8 8\n%_0 A\n%_0 Xw\n%_0 Ae\n%_u\n%_0 O\n%_0 8 m\n%_8 8 L\n%_8 0 L\n%_0 0 L\n%_0 8 L\n%_n\n\
|
||||
%_0 R\n%_0 0 0 1 K\n%_1 J 0 j 2 w 4 M []0 d\n%_4 4 m\n%_4 4 L\n%_S\n%_U\n%_/ArtDictionary :\n%_;\n%_\n%_9 () XW\nE\n%AI3_EndPattern\n";
|
||||
|
||||
/// An unpainted box that a pattern's definition marks as its tile's box isn't art; a painted one
|
||||
/// with the mark stays.
|
||||
#[test]
|
||||
fn a_patterns_marked_tile_box_is_not_art() {
|
||||
let mark = "%_/ArtDictionary :\n%_1 /Bool (AIPattern_Editor_Backing_Tile_Rect) ,\n%_;\n%_\n";
|
||||
let def = |paint: &str, op: &str| {
|
||||
format!(
|
||||
"%AI3_BeginPattern: (Box)\n(Box) 0 0 8 8\n%_0 A\n%_0 Xw\n%_{paint}0 XR\n%_0 8 m\n%_8 8 L\n%_8 0 L\n%_0 0 L\n%_0 8 L\n%_{op}\n{mark}\
|
||||
%_0 0 0 1 k\n%_2 2 m\n%_6 2 L\n%_6 6 L\n%_2 6 L\n%_f\nE\n%AI3_EndPattern\n"
|
||||
)
|
||||
};
|
||||
let layer = format!(
|
||||
"%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n(Box) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\nLB\n%AI5_EndLayer--\n",
|
||||
rect_path(10.0, 20.0, 70.0, 80.0)
|
||||
);
|
||||
let s = read_ok(&format!("{}{layer}", def("", "n")));
|
||||
let art = &s.doc.pattern("Box").unwrap().art;
|
||||
assert_eq!(art.len(), 1, "the square only");
|
||||
assert_eq!(art[0].geometric_bounds(), Some(Rect::new(2.0, -6.0, 6.0, -2.0)));
|
||||
let s = read_ok(&format!("{}{layer}", def("1 0 0 0 k\n%_", "f")));
|
||||
assert_eq!(s.doc.pattern("Box").unwrap().art.len(), 2, "a painted box is art");
|
||||
// The mark after a group or a compound path of an unpainted box leaves it in the art.
|
||||
for (open, close, kind) in [("u", "U", "group"), ("*u", "*U", "compound path")] {
|
||||
let def = format!(
|
||||
"%AI3_BeginPattern: (Box)\n(Box) 0 0 8 8\n%_0 A\n%_0 Xw\n%_{open}\n%_0 8 m\n%_8 8 L\n%_8 0 L\n%_0 0 L\n%_0 8 L\n%_n\n%_{close}\n{mark}\
|
||||
%_0 0 0 1 k\n%_2 2 m\n%_6 2 L\n%_6 6 L\n%_2 6 L\n%_f\nE\n%AI3_EndPattern\n"
|
||||
);
|
||||
let s = read_ok(&format!("{def}{layer}"));
|
||||
assert_eq!(s.doc.pattern("Box").unwrap().art.len(), 2, "after a {kind}");
|
||||
}
|
||||
// Outside a pattern's definition the mark leaves the box in the art.
|
||||
let marked = format!(
|
||||
"%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n0 XR\n{}n\n{mark}0 0 0 1 k\n{}f\nLB\n%AI5_EndLayer--\n",
|
||||
rect_path(10.0, 20.0, 70.0, 80.0),
|
||||
rect_path(20.0, 30.0, 40.0, 50.0)
|
||||
);
|
||||
let s = read_ok(&marked);
|
||||
assert_eq!(s.doc.layers[0].children().unwrap().len(), 2, "outside a pattern");
|
||||
}
|
||||
|
||||
/// Patterns are read into pattern swatches, and `p` fills with one (#1025): the art keeps its
|
||||
/// layers, the fill places the tiles by its matrix.
|
||||
#[test]
|
||||
|
||||
@@ -18,7 +18,7 @@ from the public specifications, and users' own `.ai` files are used locally only
|
||||
| | Illustrator | VectorCraft | Notes |
|
||||
|---|---:|---:|---|
|
||||
| Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS |
|
||||
| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) |
|
||||
| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) |
|
||||
| Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD |
|
||||
| **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting |
|
||||
|
||||
@@ -64,10 +64,16 @@ private editing data. VectorCraft:
|
||||
their fills and strokes (a compound path as one path of all its subpaths), process, spot and global colors, swatches and color groups, guides,
|
||||
hidden and locked objects, object names, every artboard with its name, the transparency of objects and layers
|
||||
(opacity, blending mode, isolation, knockout and knockout shape), objects' opacity masks, and clipping groups and
|
||||
clipping layers clipped by a path or compound path. Type, gradients, patterns, images, symbols, other clipping
|
||||
objects and live objects are written as plain filled and stroked paths, each with a warning; the opacity and
|
||||
blending mode of single fills and strokes, opacity masks on layers and effects other than geometry effects are
|
||||
left out.
|
||||
clipping layers clipped by a path or compound path, linear and radial gradients on fills and strokes (with their
|
||||
stops' opacity, midpoints and spot colors, elliptical radials and focal points) and gradient swatches, and
|
||||
patterns whose tiles make a grid and hold their art within the tile, with no pattern fill in that art, as
|
||||
pattern definitions with their fills, strokes and swatches. Type, other patterns, images, symbols, other clipping
|
||||
objects and live objects are written as plain filled and stroked paths, and freeform gradients, gradients along
|
||||
or across a stroke and gradients without stops, with more than 256 stops or without extent as their average
|
||||
color, each with a warning. Gradients that dither or mix their colors perceptually are written as classic
|
||||
gradients, and gradient stops linked to global process colors as their colors, each with a warning. The opacity
|
||||
and blending mode of single fills and strokes, opacity masks on layers, the placement of pattern swatches that
|
||||
move, turn or scale their pattern, and effects other than geometry effects are left out.
|
||||
- **Writes** a legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), `aiVersion: "7"`): the
|
||||
PostScript form, from the published *Adobe Illustrator File Format Specification* version 7.0
|
||||
([PRONOM fmt/423](https://www.nationalarchives.gov.uk/PRONOM/fmt/423)), which apps reading `.ai` open with its
|
||||
|
||||
Reference in New Issue
Block a user