Merge pull request #1438 from EricKuck/feat/ai-editing-data-gradients-meshes
.ai import: radial and skewed gradient matrices, focal points and stop order, gradient meshes and artboard names from the editing data
This commit is contained in:
commit
911c65de34
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
|
|
@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
|||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | `.ai` import reads gradients with their matrix (radials and skew too), focal point and stop order, gradient meshes and the artboards' names from the editing data. |
|
||||
| 2026-10-12 | The Rectangular Grid and Polar Grid tools take their options: a click opens the size, the divider counts with a skew slider each, Use Outside Rectangle As Frame (rectangular) or Create Compound Path From Ellipses (polar) and Fill Grid; the choices stay with the tool, so dragging draws with them, and scripts and agents pass them to `shape.rectangularGrid` and `shape.polarGrid` (#1377, in part). |
|
||||
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
|
||||
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
|
||||
|
|
|
|||
|
|
@ -2073,6 +2073,22 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
|||
"`",
|
||||
"~",
|
||||
"\n%AI5_EndPlace\n",
|
||||
"Xm",
|
||||
"50 -50 -100 -100 5000 -2000 Xm",
|
||||
"0 0 0 0 1e308 -1e308 Xm",
|
||||
"X!",
|
||||
"/Mesh X!",
|
||||
"/End X!",
|
||||
"X#",
|
||||
"(DeviceRGB) /CS X#",
|
||||
"[0 0] /P X#",
|
||||
"[1 0 0 1 0 0 10 10 12 10 1 8 10 1] /N X#",
|
||||
"[1e308 -1e308 0] /N X#",
|
||||
"0 /E X#",
|
||||
"[0.5 1e308 0 0 0 0 1 0 0 1] /N X#",
|
||||
"(DeviceGray) /CS X#",
|
||||
"\n%\n",
|
||||
"\n%AI5_EndPlace\n",
|
||||
"\n%%PageTrailer\n",
|
||||
])
|
||||
.prop_map(str::to_string),
|
||||
|
|
|
|||
|
|
@ -179,8 +179,12 @@ impl<'a> Lexer<'a> {
|
|||
// The count starts after the comment's line end character (a following `\n` counts).
|
||||
let from = self.pos + 1;
|
||||
let region = self.src.get(from..)?;
|
||||
let skip = region.iter().take_while(|b| is_space(**b)).count();
|
||||
if !region.get(skip..)?.starts_with(b"XI") {
|
||||
let mut skip = region.iter().take_while(|b| is_space(**b)).count();
|
||||
// A hidden object's section is on `%_` lines up to its samples: `%_XI`, then `%_%%EndData`.
|
||||
let hidden = region.get(skip..)?.starts_with(b"%_XI");
|
||||
if hidden {
|
||||
skip += 2;
|
||||
} else if !region.get(skip..)?.starts_with(b"XI") {
|
||||
return None;
|
||||
}
|
||||
let mut data = from + skip + 2;
|
||||
|
|
@ -198,7 +202,9 @@ impl<'a> Lexer<'a> {
|
|||
None => declared,
|
||||
};
|
||||
self.pos = end;
|
||||
Some(Token { tok: Tok::Data(self.src.get(data..end).unwrap_or_default()), hidden: false })
|
||||
let samples = self.src.get(data..end).unwrap_or_default();
|
||||
let samples = if hidden { samples.strip_suffix(b"%_").unwrap_or(samples) } else { samples };
|
||||
Some(Token { tok: Tok::Data(samples), hidden: false })
|
||||
}
|
||||
|
||||
/// A regular token's characters.
|
||||
|
|
@ -327,6 +333,10 @@ mod tests {
|
|||
// A section of hex lines is comments.
|
||||
let toks = all(b"%%BeginData: 9 Hex Bytes\r\n%00FF\r\n%%EndData\r\n1\r\n");
|
||||
assert_eq!(toks.last(), Some(&t(Tok::Num(1.0), false)));
|
||||
// On `%_` lines (an opacity mask's image, say) too: a `(` in the samples starts no string.
|
||||
let toks = all(b"%_XI\r%_%%BeginData: 4\r%_XI\r(\x00\x00\x00%_%%EndData\r%_XH\r1\r");
|
||||
assert!(toks.contains(&t(Tok::Data(b"(\x00\x00\x00"), true)), "{toks:?}");
|
||||
assert_eq!(toks.last(), Some(&t(Tok::Num(1.0), false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
//! Gradient meshes: `/Mesh X!`, then on `%_` lines `value /key X#` entries — the colour space
|
||||
//! (`/CS`), and per patch its place (`/P [row col]`) and four corners (`/N`, from the corner nearest
|
||||
//! the origin round to the one beside it), ended by `/E` — and `/End X!`.
|
||||
//!
|
||||
//! A corner is `[colour… opacity x y` then the handle towards the next corner, a weight, the handle
|
||||
//! towards the previous one and a weight `]`, in art space. Each patch becomes a 1×1 mesh.
|
||||
//!
|
||||
//! `X!`/`X#` aren't publicly documented (the AI7 specification predates meshes).
|
||||
|
||||
use kurbo::{Affine, Point, Vec2};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::live::{GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint};
|
||||
|
||||
use super::obj::V;
|
||||
|
||||
/// The most patches read from one mesh.
|
||||
const MAX_PATCHES: usize = 4096;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct MeshReader {
|
||||
space: String,
|
||||
corners: Vec<Vec<f64>>,
|
||||
pub patches: Vec<GradientMesh>,
|
||||
/// Something in it this doesn't read.
|
||||
pub unread: bool,
|
||||
}
|
||||
|
||||
impl MeshReader {
|
||||
/// `value /key X#`; `to_doc`: art space → the document.
|
||||
pub fn entry(&mut self, vals: &[V], to_doc: Affine) {
|
||||
let Some(at) = vals.iter().rposition(|v| matches!(v, V::Name(_))) else { return };
|
||||
let key = vals.get(at).and_then(V::text).unwrap_or_default();
|
||||
let value = at.checked_sub(1).and_then(|i| vals.get(i));
|
||||
match key.as_str() {
|
||||
"CS" => self.space = value.and_then(V::text).unwrap_or_default(),
|
||||
"N" => {
|
||||
if let Some(V::Arr(a)) = value {
|
||||
if self.corners.len() < 4 {
|
||||
self.corners.push(a.iter().filter_map(V::num).take(64).collect());
|
||||
} else {
|
||||
self.unread = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
"E" => {
|
||||
let corners = std::mem::take(&mut self.corners);
|
||||
match self.patch(&corners, to_doc) {
|
||||
Some(m) if self.patches.len() < MAX_PATCHES => self.patches.push(m),
|
||||
_ => self.unread = true,
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn color(&self, c: &[f64]) -> Option<Color> {
|
||||
let u = |i: usize| c.get(i).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32);
|
||||
Some(match (self.space.as_str(), c.len()) {
|
||||
("DeviceRGB", 3) => Color::rgb(u(0)?, u(1)?, u(2)?),
|
||||
("DeviceCMYK", 4) => Color::cmyk(u(0)?, u(1)?, u(2)?, u(3)?),
|
||||
("DeviceGray", 1) => Color::gray(1.0 - u(0)?),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn patch(&self, corners: &[Vec<f64>], to_doc: Affine) -> Option<GradientMesh> {
|
||||
let [n0, n1, n2, n3] = corners else { return None };
|
||||
// (anchor, towards the next corner, towards the previous one, colour, opacity)
|
||||
let read = |n: &Vec<f64>| -> Option<(Point, Vec2, Vec2, Color, f32)> {
|
||||
let comps = n.len().checked_sub(9)?;
|
||||
let &[opacity, x, y, nx, ny, _, px, py, _] = n.get(comps..)? else { return None };
|
||||
if ![opacity, x, y, nx, ny, px, py].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let at = to_doc * Point::new(x, y);
|
||||
let color = self.color(n.get(..comps)?)?;
|
||||
Some((at, to_doc * Point::new(nx, ny) - at, to_doc * Point::new(px, py) - at, color, opacity.clamp(0.0, 1.0) as f32))
|
||||
};
|
||||
let (a, b, c, d) = (read(n0)?, read(n1)?, read(n2)?, read(n3)?);
|
||||
// Rows run from the first corner to the second, columns from the first to the fourth.
|
||||
let point = |(p, _, _, color, opacity): (Point, Vec2, Vec2, Color, f32), handles: [(usize, Vec2); 2]| {
|
||||
let mut h = [Vec2::ZERO; 4];
|
||||
for (i, v) in handles {
|
||||
if let Some(slot) = h.get_mut(i) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
MeshPoint { p, color, opacity, handles: h }
|
||||
};
|
||||
let points = vec![
|
||||
point(a, [(H_DOWN, a.1), (H_RIGHT, a.2)]),
|
||||
point(d, [(H_LEFT, d.1), (H_DOWN, d.2)]),
|
||||
point(b, [(H_RIGHT, b.1), (H_UP, b.2)]),
|
||||
point(c, [(H_UP, c.1), (H_LEFT, c.2)]),
|
||||
];
|
||||
Some(GradientMesh { rows: 1, cols: 1, points })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn corner(values: &[f64]) -> Vec<V> {
|
||||
vec![V::Arr(values.iter().map(|v| V::Num(*v)).collect()), V::Name("N".into())]
|
||||
}
|
||||
|
||||
fn patch(reader: &mut MeshReader, first: &[f64]) {
|
||||
reader.entry(&corner(first), Affine::IDENTITY);
|
||||
for (x, y) in [(0.0, 1.0), (1.0, 1.0), (1.0, 0.0)] {
|
||||
reader.entry(&corner(&[0.5, 1.0, x, y, x, y, 1.0, x, y, 1.0]), Affine::IDENTITY);
|
||||
}
|
||||
reader.entry(&[V::Num(0.0), V::Name("E".into())], Affine::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_meshes_are_capped_and_checked() {
|
||||
let mut reader = MeshReader { space: "DeviceGray".into(), ..MeshReader::default() };
|
||||
for _ in 0..=MAX_PATCHES {
|
||||
patch(&mut reader, &[0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
assert!(reader.unread && reader.patches.len() == MAX_PATCHES);
|
||||
// NaN passes `clamp`: a non-finite opacity or colour leaves the patch unread.
|
||||
for bad in [[0.5, f64::NAN], [f64::INFINITY, 1.0]] {
|
||||
let mut reader = MeshReader { space: "DeviceGray".into(), ..MeshReader::default() };
|
||||
patch(&mut reader, &[bad[0], bad[1], 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0]);
|
||||
assert!(reader.unread && reader.patches.is_empty(), "{bad:?}");
|
||||
}
|
||||
// More corners than a patch has.
|
||||
let mut reader = MeshReader { space: "DeviceGray".into(), ..MeshReader::default() };
|
||||
for _ in 0..1000 {
|
||||
reader.entry(&corner(&[0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0]), Affine::IDENTITY);
|
||||
}
|
||||
assert!(reader.unread && reader.corners.len() == 4);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
|
||||
mod frames;
|
||||
mod lex;
|
||||
mod mesh;
|
||||
mod obj;
|
||||
mod paint;
|
||||
mod read;
|
||||
|
|
|
|||
|
|
@ -11,10 +11,13 @@
|
|||
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
|
||||
//! tx ty Bg`, the focal point `x y angle length Bh`, `flag BB`. Newer files write the gradient's
|
||||
//! place as matrices after `Bg` (whose own numbers are then not in art space): `a b c d tx ty Xm`
|
||||
//! takes the unit vector onto a linear gradient's whole ramp (0 to 100, its angle and length in
|
||||
//! it), and `a b c d tx ty Bm` takes it onto the part between two stops next to each other, one
|
||||
//! takes the unit vector (a radial's unit circle) onto the gradient's whole ramp (0 to 100, its
|
||||
//! angle and length in it), and `a b c d tx ty Bm` takes it onto the part between two stops next to each other, one
|
||||
//! `Bm` for each pair in the order of the ramp (`Bc`, around them, are the caps past the first and
|
||||
//! last stops). A radial gradient has one `Bm`, which takes the unit circle onto it.
|
||||
//! last stops). Without `Xm`, a radial gradient's one `Bm` takes the unit circle onto it.
|
||||
//!
|
||||
//! "AI7 spec": Adobe Illustrator File Format Specification, version 7.0 (1998), listed by PRONOM at
|
||||
//! https://www.nationalarchives.gov.uk/PRONOM/fmt/423; §3.5 documents the gradient operators.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
@ -155,6 +158,8 @@ impl GradientDef {
|
|||
if self.gradient.stops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Stops come last first: reversed, stops sharing a ramp point keep their order.
|
||||
self.gradient.stops.reverse();
|
||||
self.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
Some((self.name, self.gradient))
|
||||
}
|
||||
|
|
@ -170,9 +175,10 @@ pub(super) struct Instance {
|
|||
length: f64,
|
||||
/// `Bg`'s own matrix.
|
||||
bg: Affine,
|
||||
/// The matrix of a linear gradient's whole ramp (`Xm`), when there is one.
|
||||
/// `Xm`: "the overall matrix applied to the gradient" (AI7 spec §3.5.4), when there is one.
|
||||
xm: Option<Affine>,
|
||||
/// The first and the last gradient matrix (`Bm`), when there are any.
|
||||
/// The first and the last gradient matrix (`Bm`), when there are any. After `Xm` they are
|
||||
/// imaging bands (AI7 spec §3.5.6), not the gradient's matrix.
|
||||
bm: Option<(Affine, Affine)>,
|
||||
/// The focal point's offset from the origin (`Bh`).
|
||||
hilight: Vec2,
|
||||
|
|
@ -216,7 +222,7 @@ impl Instance {
|
|||
}
|
||||
}
|
||||
|
||||
/// `a b c d tx ty Xm`.
|
||||
/// `a b c d tx ty Xm`: "the overall matrix applied to the gradient" (AI7 spec §3.5.4).
|
||||
pub fn xm(&mut self, vals: &[V]) {
|
||||
self.xm = Self::matrix(vals);
|
||||
}
|
||||
|
|
@ -234,12 +240,29 @@ impl Instance {
|
|||
let name = self.name.as_ref()?;
|
||||
let gradient = defs.get(name)?.clone();
|
||||
let radial = gradient.kind == GradientKind::Radial;
|
||||
// The unit vector through `m`, the point across it (for an elliptical radial gradient) and
|
||||
// the focal point.
|
||||
let unit = |m: Affine| (m * Point::ORIGIN, m * Point::new(1.0, 0.0), m * Point::new(0.0, 1.0), m * self.hilight.to_point());
|
||||
let whole = if radial { self.bm.map(|(m, _)| m).or(self.xm) } else { self.xm };
|
||||
let (start, end, across, focal) = match (whole, self.bm) {
|
||||
(Some(m), _) => unit(to_doc * m),
|
||||
// `Xm` maps the unit circle (or vector) to the art, `Bg`'s origin, angle and length already
|
||||
// in it, and so does a radial's `Bm`: files whose PDF part draws the radial draw it through
|
||||
// that `Bm` alone, whatever `Bg` holds. The highlight is an offset from the centre in art space.
|
||||
let unit = self.xm.or(self.bm.filter(|_| radial).map(|(first, _)| first));
|
||||
// A skewed matrix leaves `along` and `across` oblique: a linear ramp runs along the normal to
|
||||
// its isolines (`across`), and a radial's ellipse is set by the matrix's principal axes.
|
||||
let through = |m: Affine, origin: Point, dir: Vec2| {
|
||||
let start = m * origin;
|
||||
let along = m * (origin + dir) - start;
|
||||
let across = m * (origin + Vec2::new(-dir.y, dir.x)) - start;
|
||||
if radial {
|
||||
let (major, minor) = principal_axes(along, across);
|
||||
(start, start + major, minor.hypot() / major.hypot())
|
||||
} else {
|
||||
let normal = Vec2::new(-across.y, across.x).normalize();
|
||||
(start, start + normal * along.dot(normal), 1.0)
|
||||
}
|
||||
};
|
||||
let (start, end, aspect, focal) = match (unit, self.bm) {
|
||||
(Some(unit), _) => {
|
||||
let (start, end, aspect) = through(to_doc * unit, Point::ORIGIN, Vec2::new(1.0, 0.0));
|
||||
(start, end, aspect, to_doc * (unit * Point::ORIGIN + self.hilight))
|
||||
}
|
||||
// A linear gradient's `Bm`s alone: they run from its first stop to its last.
|
||||
(None, Some((first, last))) => {
|
||||
let (a, b) = (to_doc * first * Point::ORIGIN, to_doc * last * Point::new(1.0, 0.0));
|
||||
|
|
@ -247,27 +270,38 @@ impl Instance {
|
|||
let to = gradient.stops.last().map_or(1.0, |s| f64::from(s.offset));
|
||||
let whole = (b - a) / (to - from).max(1e-6);
|
||||
let start = a - whole * from;
|
||||
(start, start + whole, start, start)
|
||||
(start, start + whole, 1.0, start)
|
||||
}
|
||||
// `Bg`'s own numbers, in art space through its matrix.
|
||||
(None, None) => {
|
||||
let m = to_doc * self.bg;
|
||||
let a = self.angle.to_radians();
|
||||
let dir = Vec2::new(a.cos(), a.sin()) * self.length;
|
||||
(m * self.origin, m * (self.origin + dir), m * (self.origin + Vec2::new(-dir.y, dir.x)), m * (self.origin + self.hilight))
|
||||
let (start, end, aspect) = through(m, self.origin, Vec2::new(a.cos(), a.sin()) * self.length);
|
||||
(start, end, aspect, m * (self.origin + self.hilight))
|
||||
}
|
||||
};
|
||||
if !(start.is_finite() && end.is_finite()) || start.distance(end) < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let aspect = (across.distance(start) / start.distance(end)).clamp(0.01, 100.0);
|
||||
let aspect = aspect.clamp(0.01, 100.0);
|
||||
let focal = (self.hilight.hypot() > 1e-9).then_some(focal).filter(|p| p.is_finite());
|
||||
let geom = GradientGeom { start, end, aspect: if radial { aspect } else { 1.0 }, focal: if radial { focal } else { None } };
|
||||
let geom = GradientGeom { start, end, aspect, focal: if radial { focal } else { None } };
|
||||
let angle = -(end - start).atan2().to_degrees();
|
||||
Some(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle, swatch: Some(name.clone()), ..GradientPaint::new(gradient) })))
|
||||
}
|
||||
}
|
||||
|
||||
/// The semi-axes of the ellipse the unit circle maps to under the linear map with columns `u` and `v`,
|
||||
/// the major one first.
|
||||
fn principal_axes(u: Vec2, v: Vec2) -> (Vec2, Vec2) {
|
||||
let (xx, yy, xy) = (u.x * u.x + v.x * v.x, u.y * u.y + v.y * v.y, u.x * u.y + v.x * v.y);
|
||||
let angle = 0.5 * (2.0 * xy).atan2(xx - yy);
|
||||
let spread = (0.25 * (xx - yy).powi(2) + xy * xy).sqrt();
|
||||
let (major, minor) = ((0.5 * (xx + yy) + spread).sqrt(), (0.5 * (xx + yy) - spread).max(0.0).sqrt());
|
||||
let axis = Vec2::new(angle.cos(), angle.sin());
|
||||
(axis * major, Vec2::new(-axis.y, axis.x) * minor)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -332,7 +366,7 @@ mod tests {
|
|||
let defs = HashMap::from([(name, g)]);
|
||||
let mut i = Instance::begin(&[]);
|
||||
let mut bg = vec![V::Num(1.0), V::Str(b"G".to_vec())];
|
||||
bg.extend(nums(&[0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
bg.extend(nums(&[100.0, 100.0, 0.0, 50.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
i.bg(&bg);
|
||||
i.bm(&nums(&[50.0, 0.0, 0.0, -50.0, 100.0, 100.0]));
|
||||
let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() };
|
||||
|
|
@ -341,6 +375,14 @@ mod tests {
|
|||
assert_eq!(geom.end, Point::new(150.0, 100.0));
|
||||
assert!((geom.aspect - 1.0).abs() < 1e-9);
|
||||
assert!(i.paint(&HashMap::new(), Affine::IDENTITY).is_none());
|
||||
// Newer files give the matrix with `Xm`; the `Bm`s after it are bands of a cached ramp.
|
||||
let mut i = Instance::begin(&[]);
|
||||
i.bg(&bg);
|
||||
i.xm(&nums(&[40.0, 0.0, 0.0, -40.0, 10.0, 10.0]));
|
||||
i.bm(&nums(&[5.0, 0.0, 0.0, -40.0, 30.0, 10.0]));
|
||||
let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() };
|
||||
let geom = gp.geom.unwrap();
|
||||
assert_eq!((geom.start, geom.end), (Point::new(10.0, 10.0), Point::new(50.0, 10.0)));
|
||||
}
|
||||
|
||||
/// A linear gradient of three stops as newer files place it: `Bg` with numbers that aren't in
|
||||
|
|
|
|||
|
|
@ -113,13 +113,60 @@ pub fn read(data: &[u8]) -> Result<Structure, String> {
|
|||
r.finish()
|
||||
}
|
||||
|
||||
/// The artboards (art space) of the editing data `data` that [`read`] can't read as a whole, as far
|
||||
/// as it gets: where to put the pages of its PDF part instead (#1068).
|
||||
pub fn artboards(data: &[u8]) -> Vec<Rect> {
|
||||
/// The artboards (names, and rects in art space) of the editing data `data` that [`read`] can't
|
||||
/// read as a whole, as far as it gets: where to put the pages of its PDF part instead, and what to
|
||||
/// call them (#1068).
|
||||
pub fn artboards(data: &[u8]) -> Vec<(String, Rect)> {
|
||||
let found = artboards_read(data);
|
||||
if !found.is_empty() {
|
||||
return found;
|
||||
}
|
||||
// Reading can stop before the artboards (art kept in the document's data comes first): read
|
||||
// their array on its own.
|
||||
artboard_array(data).map_or(found, |array| {
|
||||
let mut alone = b"%_/Document :\n%_/Dictionary :\n".to_vec();
|
||||
alone.extend_from_slice(array);
|
||||
alone.extend_from_slice(b"\n%_; /NotRecorded ,\n%_;\n");
|
||||
artboards_read(&alone)
|
||||
})
|
||||
}
|
||||
|
||||
/// The `ArtboardArray` entry of the editing data `data`, from its `/Array :` to its key: found
|
||||
/// from the key back, counting the dictionaries its artboards open and close.
|
||||
fn artboard_array(data: &[u8]) -> Option<&[u8]> {
|
||||
const KEY: &[u8] = b"%_; (ArtboardArray) ,";
|
||||
let end = data.windows(KEY.len()).rposition(|w| w == KEY)? + KEY.len();
|
||||
let head = data.get(..end)?;
|
||||
let mut depth = 0usize;
|
||||
let mut line_end = head.len();
|
||||
loop {
|
||||
let line_start = head.get(..line_end)?.iter().rposition(|&b| b == b'\n' || b == b'\r').map_or(0, |at| at + 1);
|
||||
if let Some(text) = head.get(line_start..line_end)?.strip_prefix(b"%_") {
|
||||
if text.starts_with(b";") || text.starts_with(b"/Def ;") {
|
||||
depth += 1;
|
||||
} else if text.starts_with(b"/") && text.windows(2).any(|w| w == b" :") {
|
||||
depth = depth.checked_sub(1)?;
|
||||
if depth == 0 {
|
||||
return head.get(line_start..);
|
||||
}
|
||||
}
|
||||
}
|
||||
line_end = line_start.checked_sub(1)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// [`artboards`] as far as reading `data` from its start gets: those read before an error, and
|
||||
/// those of a `/Document` still open when it stopped (its art can be what stopped it).
|
||||
fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> {
|
||||
let mut r = Reader::new(data);
|
||||
// Artboards read before an error still say where they are.
|
||||
let _ = r.run();
|
||||
r.artboards.into_iter().map(|(_, rect)| rect).collect()
|
||||
let open: Vec<Obj> = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect();
|
||||
for o in &open {
|
||||
if r.artboards.is_empty() {
|
||||
r.document(o);
|
||||
}
|
||||
}
|
||||
r.artboards
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -303,6 +350,8 @@ struct Reader<'a> {
|
|||
drawn_looks: usize,
|
||||
/// Nodes read from `%_` lines.
|
||||
hidden_ids: HashSet<NodeId>,
|
||||
/// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line.
|
||||
mesh: Option<(super::mesh::MeshReader, bool)>,
|
||||
/// CMYK samples converted, by value.
|
||||
cmyk_cache: HashMap<[u8; 4], [u8; 3]>,
|
||||
/// A `/ForeignObject`'s `/Data`: its PDF as ASCII85 in comment lines, until the object closes.
|
||||
|
|
@ -433,6 +482,7 @@ impl<'a> Reader<'a> {
|
|||
warnings: Vec::new(),
|
||||
drawn_looks: 0,
|
||||
hidden_ids: HashSet::new(),
|
||||
mesh: None,
|
||||
cmyk_cache: HashMap::new(),
|
||||
done: false,
|
||||
obj_hidden: false,
|
||||
|
|
@ -576,7 +626,7 @@ impl<'a> Reader<'a> {
|
|||
if let Tok::Comment(c) = t.tok {
|
||||
let line = String::from_utf8_lossy(c);
|
||||
let m = marker(&line);
|
||||
if m == begin {
|
||||
if !begin.is_empty() && m == begin {
|
||||
depth += 1;
|
||||
} else if m == end {
|
||||
if depth == 0 {
|
||||
|
|
@ -708,21 +758,7 @@ impl<'a> Reader<'a> {
|
|||
let node = match kind {
|
||||
Kind::Compound => {
|
||||
// A group in a compound path (`*u u … U *U`) only holds its paths.
|
||||
let mut kids = ungrouped(kids);
|
||||
// The compound path's paint is its last path's (a gradient's instance wraps only
|
||||
// that one); the last painted one, should the last have none.
|
||||
let Some(shown) = kids.iter().rev().find(|k| !k.appearance.items.is_empty()).or(kids.last()) else { return Ok(()) };
|
||||
let (appearance, opacity, blend, rule) = (shown.appearance.clone(), shown.opacity, shown.blend, path_rule(shown));
|
||||
for k in &mut kids {
|
||||
k.opacity = 1.0;
|
||||
k.blend = BlendMode::Normal;
|
||||
k.visible = true;
|
||||
k.locked = false;
|
||||
}
|
||||
let mut n = self.new_node(NodeKind::Compound { children: kids.into_iter().map(Arc::new).collect(), rule }, &gs);
|
||||
n.appearance = appearance;
|
||||
n.opacity = opacity;
|
||||
n.blend = blend;
|
||||
let Some(n) = self.compound(ungrouped(kids), &gs) else { return Ok(()) };
|
||||
n
|
||||
}
|
||||
// A group with a clipping path (`q` … `Q`, or a `W` path in a group) is a clipping group.
|
||||
|
|
@ -737,6 +773,25 @@ impl<'a> Reader<'a> {
|
|||
self.add(node, opened_hidden || hidden)
|
||||
}
|
||||
|
||||
/// The compound path of `kids`, painted and made transparent as its subpaths say.
|
||||
fn compound(&mut self, mut kids: Vec<Node>, gs: &GState) -> Option<Node> {
|
||||
// The compound path's paint is its last path's (a gradient's instance wraps only
|
||||
// that one); the last painted one, should the last have none.
|
||||
let shown = kids.iter().rev().find(|k| !k.appearance.items.is_empty()).or(kids.last())?;
|
||||
let (appearance, opacity, blend, rule) = (shown.appearance.clone(), shown.opacity, shown.blend, path_rule(shown));
|
||||
for k in &mut kids {
|
||||
k.opacity = 1.0;
|
||||
k.blend = BlendMode::Normal;
|
||||
k.visible = true;
|
||||
k.locked = false;
|
||||
}
|
||||
let mut n = self.new_node(NodeKind::Compound { children: kids.into_iter().map(Arc::new).collect(), rule }, gs);
|
||||
n.appearance = appearance;
|
||||
n.opacity = opacity;
|
||||
n.blend = blend;
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// `kids` with the clipping paths among them (indexes `clips`) first, several as one compound
|
||||
/// path → the children and whether they clip.
|
||||
fn clip_first(&mut self, kids: Vec<Node>, clips: &[usize]) -> (Vec<Arc<Node>>, bool) {
|
||||
|
|
@ -839,6 +894,26 @@ impl<'a> Reader<'a> {
|
|||
}
|
||||
return Ok(());
|
||||
}
|
||||
if w == "X!" || self.mesh.is_some() {
|
||||
let name = vals.iter().rev().find_map(|v| if let V::Name(n) = v { Some(n.as_str()) } else { None });
|
||||
match (w, name) {
|
||||
("X!", Some("Mesh")) => {
|
||||
self.end_path_quietly();
|
||||
self.mesh = Some((super::mesh::MeshReader::default(), hidden));
|
||||
return Ok(());
|
||||
}
|
||||
("X!", Some("End")) if self.mesh.is_some() => return self.end_mesh(),
|
||||
("X#", _) => {
|
||||
let to_doc = self.space();
|
||||
if let Some((m, _)) = self.mesh.as_mut() {
|
||||
m.entry(&vals, to_doc);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
_ if self.mesh.is_some() => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match w {
|
||||
// Containers.
|
||||
"u" => {
|
||||
|
|
@ -1238,10 +1313,14 @@ impl<'a> Reader<'a> {
|
|||
if let Some(n) = named {
|
||||
self.spots.push(n);
|
||||
}
|
||||
// A colour set after a gradient instance that painted nothing (`Bb` … `0 BB` with no
|
||||
// path) is the next object's paint, not the gradient.
|
||||
if w.ends_with(|c: char| c.is_ascii_lowercase()) {
|
||||
self.gs.fill = p;
|
||||
self.pending_fill = None;
|
||||
} else {
|
||||
self.gs.stroke = p;
|
||||
self.pending_stroke = None;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1417,6 +1496,24 @@ impl<'a> Reader<'a> {
|
|||
self.add(node, hidden)
|
||||
}
|
||||
|
||||
/// `/End X!`: the gradient mesh read, as one object.
|
||||
fn end_mesh(&mut self) -> Result<(), String> {
|
||||
let Some((m, hidden)) = self.mesh.take() else { return Ok(()) };
|
||||
if m.unread || m.patches.is_empty() {
|
||||
self.unreadable("gradient meshes it can't read");
|
||||
return Ok(());
|
||||
}
|
||||
let gs = self.gs.clone();
|
||||
let mut patches: Vec<Node> = m.patches.into_iter().map(|p| self.new_node(NodeKind::Mesh(p), &gs)).collect();
|
||||
let mut node = match patches.len() {
|
||||
1 => patches.pop().unwrap_or_else(|| self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs)),
|
||||
_ => self.new_node(NodeKind::Group { children: patches.into_iter().map(Arc::new).collect(), clip: false }, &gs),
|
||||
};
|
||||
node.opacity = gs.opacity;
|
||||
node.blend = gs.blend;
|
||||
self.add(node, hidden)
|
||||
}
|
||||
|
||||
fn set_instance(&mut self, i: &Instance) {
|
||||
let to_doc = self.space();
|
||||
match i.paint(&self.gradients, to_doc) {
|
||||
|
|
|
|||
|
|
@ -285,9 +285,13 @@ fn gradients() {
|
|||
let defs = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\
|
||||
%_0 0 0 0 1 0 0 2 1 6 40 0 Bs\n%_0 0 0 0 0 0 1 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let art_ = format!(
|
||||
"{RED}{}Bb\n0 0 0 0 Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n{}f\n",
|
||||
"{RED}{}Bb\n0 0 0 0 Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n{}f\n\
|
||||
Bb\n2 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 0 0 0 0 0 1 Xa\n{}f\n\
|
||||
{}Bb\n-20 -10 144 0.77 Bh\n1 (G) -0.1 0.4 0 1.35 1 0 0 -1 0 16384 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n",
|
||||
rect_path(50.0, 0.0, 150.0, 100.0),
|
||||
rect_path(0.0, 0.0, 10.0, 10.0),
|
||||
rect_path(20.0, 0.0, 30.0, 10.0),
|
||||
rect_path(50.0, 0.0, 150.0, 100.0),
|
||||
);
|
||||
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
|
||||
let a = art(&doc);
|
||||
|
|
@ -300,6 +304,15 @@ fn gradients() {
|
|||
assert_eq!((geom.start, geom.end), (Point::new(100.0, 50.0), Point::new(150.0, 50.0)));
|
||||
// The gradient was the one object's.
|
||||
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
// A gradient instance that paints nothing doesn't stick to the next object, which sets its colour.
|
||||
assert_eq!(fill(&a[2]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
// A radial gradient is its matrix's unit circle whatever `Bg`'s origin and length, and its
|
||||
// highlight an offset from the centre in art space.
|
||||
let Paint::Gradient(g) = fill(&a[3]) else { panic!("{:?}", fill(&a[3])) };
|
||||
let geom = g.geom.unwrap();
|
||||
assert_eq!((geom.start, geom.end), (Point::new(100.0, 50.0), Point::new(150.0, 50.0)));
|
||||
let focal = geom.focal.unwrap();
|
||||
assert!((focal - Point::new(80.0, 60.0)).hypot() < 1e-6, "{focal:?}");
|
||||
// A linear gradient with a stop in the middle: `Xm` places its whole ramp, a `Bm` each of its
|
||||
// two parts. The object's own note of its gradient after it paints nothing.
|
||||
let defs = "(L) 0 3 Bd\n%_1 0 50 100 Bs\n%_0.5 0 50 40 Bs\n%_0 0 50 0 Bs\nBD\n";
|
||||
|
|
@ -319,6 +332,43 @@ fn gradients() {
|
|||
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "the note after the object isn't the next path's paint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_through_a_skewed_matrix() {
|
||||
// GUARDS: a sheared `Xm` leaves the matrix's columns oblique. A linear ramp runs along the
|
||||
// normal to its isolines, not along its first column; a radial's ellipse is the one the unit
|
||||
// circle maps to, set by the principal axes.
|
||||
let defs = |name: &str, kind: u8| {
|
||||
format!(
|
||||
"%AI5_BeginGradient: ({name})\n({name}) {kind} 2 Bd\n[\n<00FF>\n0\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 instance = |name: &str| format!("Bb\n1 ({name}) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 10 10 100 50 Xm\nf\n0 BB\n");
|
||||
let art_ = format!("{}{}{}{}", rect_path(0.0, 0.0, 200.0, 100.0), instance("Lin"), rect_path(0.0, 0.0, 200.0, 100.0), instance("Rad"));
|
||||
let doc = read_ok(&format!("{}{}{}", defs("Lin", 0), defs("Rad", 1), layer(&art_))).doc;
|
||||
let a = art(&doc);
|
||||
let Paint::Gradient(g) = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
|
||||
let geom = g.geom.unwrap();
|
||||
assert!((geom.start - Point::new(100.0, 50.0)).hypot() < 1e-9 && (geom.end - Point::new(105.0, 55.0)).hypot() < 1e-9, "{geom:?}");
|
||||
let Paint::Gradient(g) = fill(&a[1]) else { panic!("{:?}", fill(&a[1])) };
|
||||
let geom = g.geom.unwrap();
|
||||
let golden = (1.0 + 5f64.sqrt()) / 2.0;
|
||||
assert!((geom.start.distance(geom.end) - 10.0 * golden).abs() < 1e-9, "{geom:?}");
|
||||
assert!((geom.aspect - 1.0 / golden.powi(2)).abs() < 1e-9, "{geom:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_sharing_a_ramp_point_keep_their_order() {
|
||||
// GUARDS: stops come last first, so a sort alone puts the two at 100 the wrong way round and
|
||||
// the ramp blends towards the colour meant only for past its end.
|
||||
let defs = "%AI5_BeginGradient: (S)\n(S) 0 3 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 100 Bs\n\
|
||||
%_0 0 0 0 0 1 0 2 1 6 50 100 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 0 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let art_ = format!("{}Bb\n1 (S) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 0 10 100 50 Xm\nf\n0 BB\n", rect_path(0.0, 0.0, 200.0, 100.0));
|
||||
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
|
||||
let Paint::Gradient(g) = fill(&art(&doc)[0]) else { panic!() };
|
||||
let colors: Vec<_> = g.gradient.stops.iter().map(|s| s.color).collect();
|
||||
assert_eq!(colors, [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 0.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboards() {
|
||||
let data = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
|
||||
|
|
@ -738,3 +788,70 @@ fn closing_brackets_without_their_opening_dont_rescan_the_stack() {
|
|||
let _ = read(stream(&layer(&body)).as_bytes());
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboards_after_what_stops_the_reading() {
|
||||
let board = |name: &str, x: i32| {
|
||||
format!(
|
||||
"%_/Dictionary :\n%_/FillStyle : 0 1 0 0 0 Xy\n%_/Def ; \n%_ (EachArtboardColor) ,\n%_{x} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_({name}) /UnicodeString (Name) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
|
||||
x + 100
|
||||
)
|
||||
};
|
||||
let array = format!("%_/Array :\n{}{}%_; (ArtboardArray) ,\n", board("one", 0), board("two", 200));
|
||||
// Art in the document's own data that the reader can't follow comes before the artboards.
|
||||
let data = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n%_/Dictionary :\nLB\n{array}%_; /NotRecorded ,\n%_;\n");
|
||||
let boards: Vec<_> = super::artboards(data.as_bytes()).into_iter().map(|(n, r)| (n, r.x0)).collect();
|
||||
assert_eq!(boards, [("one".to_string(), 0.0), ("two".to_string(), 200.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radial_gradient_without_xm_is_its_bm_circle_else_its_bg_circle() {
|
||||
// GUARDS: files write `Bg` as the unit circle and place the radial with `Bm`; reading `Bg` alone collapses it.
|
||||
let defs =
|
||||
"%AI5_BeginGradient: (R)\n(R) 1 2 Bd\n[\n<00FF>\n0\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 geom = |instance: &str| {
|
||||
let doc = read_ok(&format!("{defs}{}", layer(&format!("{}Bb\n10 0 0 0.5 Bh\n{instance}f\n0 BB\n", rect_path(0.0, 0.0, 200.0, 100.0))))).doc;
|
||||
let Paint::Gradient(g) = fill(&art(&doc)[0]) else { panic!() };
|
||||
g.geom.unwrap()
|
||||
};
|
||||
let placed = geom("1 (R) 0 0 0 1 1 0 0 1 0 0 Bg\n50 0 0 -50 100 50 Bm\n");
|
||||
assert_eq!((placed.start, placed.end, placed.focal), (Point::new(100.0, 50.0), Point::new(150.0, 50.0), Some(Point::new(110.0, 50.0))));
|
||||
let own = geom("1 (R) 60 50 0 20 1 0 0 1 0 0 Bg\n");
|
||||
assert_eq!((own.start, own.end, own.focal), (Point::new(60.0, 50.0), Point::new(80.0, 50.0), Some(Point::new(70.0, 50.0))));
|
||||
}
|
||||
|
||||
/// Two art styles: `Blue`, a blue fill and a drop shadow; `Lit`, the object's gradient and an outer glow.
|
||||
#[test]
|
||||
fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() {
|
||||
// GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none.
|
||||
let r = read_ok(&format!("%%BeginProlog\n%\n%%EndProlog\n{}", layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)))));
|
||||
assert_eq!(art(&r.doc).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_mesh_reads_as_a_patch() {
|
||||
let corner = |rgb: &str, x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| {
|
||||
format!("%_[{rgb} 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1)
|
||||
};
|
||||
let mesh = |space: &str| {
|
||||
format!(
|
||||
"/Mesh X!\n%_({space}) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n/End X!\n",
|
||||
corner("1 0 0", 0, 0, (0, 10), (10, 0)),
|
||||
corner("0 1 0", 0, 40, (10, 40), (0, 30)),
|
||||
corner("0 0 1", 40, 40, (40, 30), (30, 40)),
|
||||
corner("1 1 1", 40, 0, (30, 0), (40, 10)),
|
||||
)
|
||||
};
|
||||
let doc = read_ok(&layer(&mesh("DeviceRGB"))).doc;
|
||||
let NodeKind::Mesh(m) = &art(&doc)[0].kind else { panic!("{:?}", art(&doc)[0].kind) };
|
||||
let colors: Vec<_> = m.points.iter().map(|p| p.color).collect();
|
||||
assert_eq!(colors, [Color::rgb(1.0, 0.0, 0.0), Color::rgb(1.0, 1.0, 1.0), Color::rgb(0.0, 1.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]);
|
||||
use vectorcraft_doc::live::{H_DOWN, H_RIGHT};
|
||||
let first = &m.points[0];
|
||||
assert_eq!(
|
||||
(first.p, first.handles[H_DOWN], first.handles[H_RIGHT]),
|
||||
(Point::new(0.0, 100.0), kurbo::Vec2::new(0.0, -10.0), kurbo::Vec2::new(10.0, 0.0))
|
||||
);
|
||||
let e = read(stream(&layer(&mesh("Separation"))).as_bytes()).unwrap_err();
|
||||
assert!(e.contains("gradient meshes"), "{e}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1160,19 +1160,25 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
|
|||
}
|
||||
}
|
||||
|
||||
/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) moved, with
|
||||
/// the art on each, to where `boards` (the artboards of its editing data, in art space) are, so
|
||||
/// the artboards keep their layout when the layers can't be read (#1068). Left as they are unless
|
||||
/// there are as many artboards as pages, of the same sizes.
|
||||
fn place_pages(boards: &[Rect], doc: &mut Document) {
|
||||
let (Some(first), true) = (boards.first(), boards.len() == doc.artboards.len() && boards.len() > 1) else { return };
|
||||
if boards.iter().zip(&doc.artboards).any(|(b, a)| (b.width() - a.rect.width()).abs() > 0.5 || (b.height() - a.rect.height()).abs() > 0.5) {
|
||||
/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) named as
|
||||
/// `boards` (the artboards of its editing data, in art space) name them, and moved, with the art on
|
||||
/// each, to where they are, so the artboards keep their names and layout when the layers can't be
|
||||
/// read (#1068). Left as they are unless there are as many artboards as pages, of the same sizes.
|
||||
fn place_pages(boards: &[(String, Rect)], doc: &mut Document) {
|
||||
if boards.is_empty() || boards.len() != doc.artboards.len() {
|
||||
return;
|
||||
}
|
||||
if boards.iter().zip(&doc.artboards).any(|((_, b), a)| (b.width() - a.rect.width()).abs() > 0.5 || (b.height() - a.rect.height()).abs() > 0.5) {
|
||||
return;
|
||||
}
|
||||
for ((name, _), a) in boards.iter().zip(&mut doc.artboards) {
|
||||
a.name.clone_from(name);
|
||||
}
|
||||
let [(_, first), _, ..] = boards else { return };
|
||||
// Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it.
|
||||
let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -first.x0, first.y1]);
|
||||
let pages: Vec<Rect> = doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let moves: Vec<Vec2> = boards.iter().zip(&pages).map(|(b, page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect();
|
||||
let moves: Vec<Vec2> = boards.iter().zip(&pages).map(|((_, b), page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect();
|
||||
for layer in &mut doc.layers {
|
||||
place_art(Arc::make_mut(layer), &pages, &moves);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,13 +239,13 @@ fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
|
|||
}
|
||||
|
||||
/// A `.ai` whose layers can't be read comes in as its PDF part, a page per artboard in a row; its
|
||||
/// pages still go where its editing data puts its artboards, with their art (#1068).
|
||||
/// pages still go where its editing data puts its artboards, with their art, and keep their names (#1068).
|
||||
#[test]
|
||||
fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
|
||||
// Two 100-point artboards, the second below the first (art space, y up).
|
||||
let board = |x0: i32, y0: i32| {
|
||||
format!(
|
||||
"%_/Dictionary :\n%_{x0} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {y0} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
|
||||
"%_/Dictionary :\n%_{x0} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {y0} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_(at {x0} {y0}) /UnicodeString (Name) ,\n%_; ,\n",
|
||||
y0 + 100,
|
||||
x0 + 100
|
||||
)
|
||||
|
|
@ -271,6 +271,8 @@ fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
|
|||
assert!(!read && w.iter().any(|w| w.contains("layers weren't read")), "{w:?}");
|
||||
let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)]);
|
||||
let board_names: Vec<_> = d.artboards.iter().map(|a| a.name.as_str()).collect();
|
||||
assert_eq!(board_names, ["at 0 0", "at 0 -150"]);
|
||||
let moved = squares(&d);
|
||||
assert!((moved[0] - squares(&pdf)[0]).hypot() < 1e-6, "the first page's art stays: {moved:?}");
|
||||
assert!((moved[1] - vectorcraft_geom::Point::new(10.0, 160.0)).hypot() < 1e-6, "the second's goes with its page: {moved:?}");
|
||||
|
|
@ -278,6 +280,7 @@ fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
|
|||
for list in [&[(0, 0)][..], &[(0, 0), (0, -150), (200, 0)]] {
|
||||
let (d, _, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false);
|
||||
assert_eq!(d.artboards[1].rect, pdf.artboards[1].rect, "{list:?}");
|
||||
assert_eq!(d.artboards[1].name, pdf.artboards[1].name, "{list:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue