mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
Part of #868: legacy .ai writer to the published version 7 specification
export_legacy_ai writes a document as a PostScript-form .ai of version 7 (one artboard per file), keeping every layer (name, order, Hidden, Locked) and its groups, compound paths and clip groups. Closed paths end on their start point, so they keep their corner. The .ai reader learns what the writer emits, and the tests read every file back.
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
//! - A line starting with `%_` holds tokens like any other line (they are marked [`Token::hidden`]:
|
||||
//! readers of the printed format skip them as comments).
|
||||
//! - Any other line starting with `%` is a [`Tok::Comment`] (the section markers).
|
||||
//! - `%` later in a line starts a comment that is skipped.
|
||||
//! - `%_` later in a line starts tokens too, hidden (a pseudo-comment operator after its
|
||||
//! operands, as a gradient stop's `… 50 100 %_Bs`: AI7 spec §1.4.3); any other `%` later in a
|
||||
//! line starts a comment that is skipped.
|
||||
//! - The binary image data after `%%BeginData:` is one [`Tok::Data`]; the binary preview of a placed
|
||||
//! file is one [`Tok::Preview`].
|
||||
|
||||
@@ -118,7 +120,8 @@ impl<'a> Lexer<'a> {
|
||||
}
|
||||
if b == b'%' {
|
||||
let start = self.at_line_start();
|
||||
if start && self.rest().starts_with(b"%_") {
|
||||
// A pseudo-comment: `%_` at the line's start, or after operands.
|
||||
if self.rest().starts_with(b"%_") && (start || self.rest().get(2).is_some_and(|b| b.is_ascii_alphabetic())) {
|
||||
self.pos += 2;
|
||||
self.hidden = true;
|
||||
continue;
|
||||
@@ -313,11 +316,20 @@ mod tests {
|
||||
t(Tok::Word(b":"), true),
|
||||
t(Tok::Word(b";"), true),
|
||||
t(Tok::Num(0.0), false),
|
||||
// A pseudo-comment operator after its operands.
|
||||
t(Tok::Word(b"BS"), true),
|
||||
t(Tok::Hex(b"AB".to_vec()), false),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_percent_later_in_a_line_is_a_comment_unless_an_operator_follows_it() {
|
||||
let toks = all(b"1 % a note (\n2 %_Bs\n3 %_ 4\n");
|
||||
let words: Vec<_> = toks.iter().map(|t| (t.tok.clone(), t.hidden)).collect();
|
||||
assert_eq!(words, [(Tok::Num(1.0), false), (Tok::Num(2.0), false), (Tok::Word(b"Bs"), true), (Tok::Num(3.0), false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_data_is_one_token() {
|
||||
// The count starts after the comment's `\r`: `\nXI\n` and 3 samples; an alpha channel
|
||||
|
||||
@@ -64,6 +64,8 @@ const MAX_NODES: usize = 4_000_000;
|
||||
const MAX_POINTS: usize = 4_000_000;
|
||||
/// Largest image read (pixels).
|
||||
const MAX_PIXELS: u64 = 1 << 25;
|
||||
/// Most hex digits of an image's samples read from comment lines (CMYK and alpha at the largest size).
|
||||
const MAX_HEX: usize = (MAX_PIXELS as usize) * 10;
|
||||
/// Largest side of the document and its artboards (points).
|
||||
const MAX_SIDE: f64 = 1e6;
|
||||
/// Art space (y up) → a pattern's space in the document's terms (y down).
|
||||
@@ -291,6 +293,11 @@ struct PatternHead {
|
||||
#[derive(Debug, Default)]
|
||||
struct Raster {
|
||||
space: Option<String>,
|
||||
/// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to
|
||||
/// `%AI5_EndRaster`, the form the version 7 specification documents (§8).
|
||||
operands: Option<Vec<V>>,
|
||||
/// Those hex digits.
|
||||
hex: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A placed file's operands, from `` ` `` and its preview, until `~`.
|
||||
@@ -566,6 +573,16 @@ impl<'a> Reader<'a> {
|
||||
fn comment(&mut self, c: &[u8]) -> Result<(), String> {
|
||||
let line = String::from_utf8_lossy(c);
|
||||
let line = line.trim_end();
|
||||
if let Some(r) = self.raster.as_mut()
|
||||
&& r.operands.is_some()
|
||||
&& !line.is_empty()
|
||||
&& line.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
{
|
||||
if r.hex.len() < MAX_HEX {
|
||||
r.hex.extend_from_slice(line.as_bytes());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let m = marker(line);
|
||||
// A pattern's definition (one inside another's isn't read).
|
||||
if m == "BeginPattern" && self.pattern.is_none() {
|
||||
@@ -597,7 +614,9 @@ impl<'a> Reader<'a> {
|
||||
if line.starts_with("AI5_BeginRaster") {
|
||||
self.raster = Some(Raster::default());
|
||||
} else if line.starts_with("AI5_EndRaster") {
|
||||
self.raster = None;
|
||||
if let Some(Raster { operands: Some(vals), hex, .. }) = self.raster.take() {
|
||||
return self.image_from(vals, &hex);
|
||||
}
|
||||
} else if line.starts_with("AI5_BeginPlace") {
|
||||
self.place = Some(Place::default());
|
||||
} else if line.starts_with("AI5_EndPlace") {
|
||||
@@ -1067,6 +1086,10 @@ impl<'a> Reader<'a> {
|
||||
{
|
||||
self.set_instance(&i);
|
||||
}
|
||||
// `1 BB` strokes the path the gradient just filled, `2 BB` closes it first (§3.5.3).
|
||||
if matches!(Self::nums(&vals).last(), Some(f) if *f == 1.0 || *f == 2.0) {
|
||||
self.stroke_last();
|
||||
}
|
||||
}
|
||||
// Images.
|
||||
"XN" => {
|
||||
@@ -1088,6 +1111,12 @@ impl<'a> Reader<'a> {
|
||||
"~" if self.place.is_some() => self.end_place(hidden)?,
|
||||
// Type in the legacy format.
|
||||
"To" | "TO" | "Tp" | "TP" | "Tx" | "TX" | "Tj" | "Tk" => self.unreadable("type in the legacy format"),
|
||||
// An image whose samples follow in comment lines (read at `%AI5_EndRaster`).
|
||||
"XI" if self.raster.is_some() => {
|
||||
if let Some(r) = self.raster.as_mut() {
|
||||
r.operands = Some(vals);
|
||||
}
|
||||
}
|
||||
_ if IGNORED.contains(&w) => {}
|
||||
// Before the art, the setup runs procedures of the printing format.
|
||||
_ if self.frames.is_empty() => {}
|
||||
@@ -1612,6 +1641,24 @@ impl<'a> Reader<'a> {
|
||||
self.add(node, hidden)
|
||||
}
|
||||
|
||||
/// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state.
|
||||
fn stroke_last(&mut self) {
|
||||
let gs = self.gs.clone();
|
||||
let Some(n) = self.last_object() else { return };
|
||||
if !matches!(n.kind, NodeKind::Path { clipping: false, .. }) || n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) {
|
||||
return;
|
||||
}
|
||||
let mut st = StrokeLayer::new(gs.stroke.clone(), gs.width);
|
||||
st.cap = gs.cap;
|
||||
st.join = gs.join;
|
||||
st.miter_limit = gs.miter;
|
||||
st.dash = gs.dash.clone();
|
||||
st.overprint = gs.overprint_stroke;
|
||||
let mut a = (*n.appearance).clone();
|
||||
a.items.push(AppearanceItem::Stroke(st));
|
||||
n.appearance = a.into();
|
||||
}
|
||||
|
||||
fn set_instance(&mut self, i: &Instance) {
|
||||
let to_doc = self.space();
|
||||
match i.paint(&self.gradients, to_doc) {
|
||||
@@ -1629,6 +1676,16 @@ impl<'a> Reader<'a> {
|
||||
/// The samples after `XI`, its operands on the stack.
|
||||
fn image(&mut self, data: &[u8]) -> Result<(), String> {
|
||||
let vals = self.operands();
|
||||
// Operands that came before an `XI` on their own line.
|
||||
let vals = match self.raster.as_mut().and_then(|r| r.operands.take()) {
|
||||
Some(before) if !vals.iter().any(|v| matches!(v, V::Arr(_))) => before,
|
||||
_ => vals,
|
||||
};
|
||||
self.image_from(vals, data)
|
||||
}
|
||||
|
||||
/// An image of operands `vals` and samples `data`.
|
||||
fn image_from(&mut self, vals: Vec<V>, data: &[u8]) -> Result<(), String> {
|
||||
let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default();
|
||||
let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else { return Ok(()) };
|
||||
let n = Self::nums(&vals);
|
||||
@@ -1753,7 +1810,11 @@ impl<'a> Reader<'a> {
|
||||
/// box's centre (half a point off the grid).
|
||||
fn sized_board(&self) -> Option<Rect> {
|
||||
let ((w, h), (cx, cy)) = (self.art_size?, self.template_center?);
|
||||
let (x0, y1) = ((cx - w / 2.0).floor(), (cy + h / 2.0).ceil());
|
||||
// Rounded out, but not off a whole point the decimals in the file only just miss.
|
||||
let near = |v: f64| (v - v.round()).abs() < 1e-6;
|
||||
let (left, top) = (cx - w / 2.0, cy + h / 2.0);
|
||||
let x0 = if near(left) { left.round() } else { left.floor() };
|
||||
let y1 = if near(top) { top.round() } else { top.ceil() };
|
||||
let r = Rect::new(x0, y1 - h, x0 + w, y1);
|
||||
(sane(r) && w > 0.0 && h > 0.0).then_some(r)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,951 @@
|
||||
//! Legacy `.ai`, version 7: the PostScript-form Illustrator format, written so that apps that read
|
||||
//! it get the document's layers, groups, compound paths and clipping groups as editable objects (a
|
||||
//! PDF-compatible `.ai` without Illustrator's undocumented editing data opens in them as one
|
||||
//! clipped group per page).
|
||||
//!
|
||||
//! The format is the one the published specification documents, and only that: Adobe Illustrator
|
||||
//! File Format Specification, version 7.0 (23 February 1998), listed by PRONOM at
|
||||
//! https://www.nationalarchives.gov.uk/PRONOM/fmt/423 (its specification link:
|
||||
//! http://mercator.elte.hu/~saman/hu/okt/AI7FileFormat.pdf). Section numbers below are its. This
|
||||
//! writer emits:
|
||||
//!
|
||||
//! - Header comments (§2.1): `%%BoundingBox` (the one it requires), `%%CreationDate: (date)
|
||||
//! (time)`, custom (spot) colours, `%AI5_FileFormat 3` (version 7.0, Table 2), the artboard as
|
||||
//! `%AI5_ArtSize` and an empty `%AI3_TemplateBox` at its centre, which puts the ruler origin at
|
||||
//! its bottom-left corner (§2.2), `%AI5_NumLayers`.
|
||||
//! - Setup (§3): `n Bn`, then each gradient: `%AI5_BeginGradient: (name)`, `(name) type n Bd`,
|
||||
//! `[`, its stops last first (`colorSpec colorStyle midPoint rampPoint %_Bs`), `BD`,
|
||||
//! `%AI5_EndGradient`.
|
||||
//! - Layers (§9): `%AI5_BeginLayer`, `visible preview enabled printing dimmed 0 colorIndex r g b
|
||||
//! Lb`, `(name) Ln`, the art, `LB`, `%AI5_EndLayer--`.
|
||||
//! - Groups `u` … `U`, clipping groups `q` … `Q` with the clipping path painted `h W n` (§5.6),
|
||||
//! compound paths `*u` … `*U` (§5.3.5), locked objects `1 A` (§5.1).
|
||||
//! - Paths (§5.3): `m`, `l`/`L`, `c`/`C` (lower case: the new point is smooth, upper case: a
|
||||
//! corner), painted `N n F f S s B b` (lower case closes first); guides `(N) *` before and after
|
||||
//! the path (§6); fill rule `XR`; overprint `O`/`R`; stroke `w J j M d`.
|
||||
//! - Colours (§5.4): `g`/`G` (grey), `k`/`K` (CMYK), `Xa`/`XA` (`r g b`), `x`/`X` (a spot colour
|
||||
//! at a tint). Gradient fills (§3.5.3): the path, `Bb`, `1 (name) x y angle length 1 0 0 1 0 0
|
||||
//! Bg`, a radial's highlight `x y angle fraction Bh`, `f` (or `F`), and `0 BB`, or `2 BB` (`1 BB`
|
||||
//! open) to stroke it too.
|
||||
//! - Images (§8): `%AI5_BeginRaster`, `[matrix] 0 0 w h w h 8 3 0 0 0 0 XI`, the RGB samples in
|
||||
//! hex on comment lines, `%AI5_EndRaster`.
|
||||
//!
|
||||
//! The format has one artboard, no transparency, sublayers, object names, live effects, symbols
|
||||
//! or appearances with several fills and strokes. Callers write one file per artboard and expand
|
||||
//! appearances first; what is left is written as the format can hold it, each loss a warning:
|
||||
//! transparent objects are written opaque, sublayers become groups, type its outlines, live objects and symbols their
|
||||
//! art, strokes a plain line can't draw their outlines, pattern fills their tiles clipped to the
|
||||
//! shape, freeform gradients their average colour, elliptical radial gradients circular (the
|
||||
//! gradient matrix is relative to a canvas the specification doesn't size), transparent image
|
||||
//! pixels over white. Hidden
|
||||
//! objects are left out (hidden layers stay, hidden).
|
||||
//!
|
||||
//! No prolog is written: the file names none, so PostScript printers can't print it, as the `.ai`
|
||||
//! files other apps write in this format.
|
||||
|
||||
use std::fmt::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::cms::Cms;
|
||||
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{
|
||||
AppearanceItem, Document, FillLayer, ImageObject, LAYER_COLORS, LayerColor, LineCap, LineJoin, Node, NodeKind, StrokeLayer, TextObject,
|
||||
};
|
||||
use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, AnchorKind, BezPath, FillRule, PathData, Point, Rect, Shape, SubPath, Vec2};
|
||||
|
||||
use crate::ps;
|
||||
|
||||
/// How deep symbols and live objects may nest in one another (deeper art is left out).
|
||||
const MAX_NEST: u32 = 8;
|
||||
/// Deepest nesting of groups and layers written (deeper art is left out).
|
||||
const MAX_DEPTH: u32 = 256;
|
||||
/// Largest image written (pixels).
|
||||
const MAX_IMAGE_PIXELS: u64 = 1 << 26;
|
||||
/// Hex digits on a line of image data.
|
||||
const HEX_LINE: usize = 64;
|
||||
|
||||
pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)";
|
||||
pub const TYPE: &str = "type is written as outlines (legacy .ai type isn't written yet)";
|
||||
pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)";
|
||||
pub const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is written opaque: legacy .ai files have none";
|
||||
pub const NAMES: &str = "object names are left out (legacy .ai files name only layers)";
|
||||
const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the shape";
|
||||
const FREEFORM: &str = "freeform gradients are written as their average colour";
|
||||
const STROKES: &str =
|
||||
"strokes a plain line can't draw (inside or outside the path, variable width, gradients, brushes, arrowheads) are written as filled outlines";
|
||||
const IMAGE_ALPHA: &str = "transparent image pixels are written over white";
|
||||
const LIVE: &str = "symbols, blends, envelopes, meshes, repeats and placed documents are written as their art";
|
||||
const UNREADABLE_IMAGE: &str = "an image that can't be decoded or is too large was left out";
|
||||
const DEEP: &str = "art nested too deeply was left out";
|
||||
const ELLIPTICAL: &str = "elliptical radial gradients are written circular";
|
||||
|
||||
/// What to write in the header.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LegacyAiOptions {
|
||||
/// `%%Title`.
|
||||
pub title: String,
|
||||
/// `%%CreationDate`, Unix seconds.
|
||||
pub created: Option<i64>,
|
||||
}
|
||||
|
||||
/// The file and what it couldn't hold.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LegacyAiOutput {
|
||||
pub bytes: Vec<u8>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Write `doc`'s first artboard and every layer (all its art, on the artboard or not) as a
|
||||
/// legacy `.ai` file of version 7. Art space is y up, its origin at the artboard's left edge and
|
||||
/// just under its bottom edge: the artboard's top is on a whole point, where readers that place
|
||||
/// the artboard by rounding the template box out to whole points put it too.
|
||||
pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiOutput, String> {
|
||||
let board = doc.artboards.first().ok_or("the document has no artboard")?.rect;
|
||||
if !(board.is_finite() && board.width() > 0.0 && board.height() > 0.0) {
|
||||
return Err("the document has no artboard".into());
|
||||
}
|
||||
// The document is y down; art space y up from the artboard's left edge, its top on a whole point.
|
||||
let (width, height) = (board.width(), board.height());
|
||||
let top = if (height - height.round()).abs() < 1e-6 { height.round() } else { height.ceil() };
|
||||
let below = top - height;
|
||||
let to_art = Affine::new([1.0, 0.0, 0.0, -1.0, -board.x0, board.y1 + below]);
|
||||
let mut w = Writer::new(doc, to_art);
|
||||
let mut layers = 0usize;
|
||||
for layer in &doc.layers {
|
||||
if w.layer(layer) {
|
||||
layers += 1;
|
||||
}
|
||||
}
|
||||
let art = doc
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.visible)
|
||||
.filter_map(|l| l.visual_bounds())
|
||||
.map(|b| to_art.transform_rect_bbox(b))
|
||||
.filter(|b| b.is_finite())
|
||||
.reduce(|a, b| a.union(b))
|
||||
.unwrap_or(Rect::new(0.0, below, width, top));
|
||||
let mut s = String::new();
|
||||
s.push_str(PS_HEADER);
|
||||
s.push('\n');
|
||||
let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION"));
|
||||
let _ = writeln!(s, "%%Title: {}", ps::string(&o.title));
|
||||
if let Some(t) = o.created {
|
||||
let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t);
|
||||
let _ = writeln!(s, "%%CreationDate: ({mo}/{d}/{y:04}) ({h:02}:{mi:02})");
|
||||
}
|
||||
let _ = writeln!(s, "%%BoundingBox: {} {} {} {}", art.x0.floor() as i64, art.y0.floor() as i64, art.x1.ceil() as i64, art.y1.ceil() as i64);
|
||||
s.push_str("%%HiResBoundingBox: ");
|
||||
ps::push_nums(&mut s, &[art.x0, art.y0, art.x1, art.y1]);
|
||||
s.pop();
|
||||
s.push('\n');
|
||||
s.push_str("%%DocumentProcessColors: Cyan Magenta Yellow Black\n");
|
||||
for (i, (name, _)) in w.spots.iter().enumerate() {
|
||||
let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, ps::string(name));
|
||||
}
|
||||
for (i, (name, cmyk)) in w.spots.iter().enumerate() {
|
||||
s.push_str(if i == 0 { "%%CMYKCustomColor: " } else { "%%+ " });
|
||||
ps::push_nums(&mut s, &cmyk.map(f64::from));
|
||||
let _ = writeln!(s, "{}", ps::string(name));
|
||||
}
|
||||
s.push_str("%AI5_FileFormat 3\n%AI3_ColorUsage: Color\n");
|
||||
// An empty template box at the artboard's centre sets the ruler origin (§2.2).
|
||||
let (cx, cy) = (ps::num(width / 2.0), ps::num(below + height / 2.0));
|
||||
let _ = writeln!(s, "%AI3_TemplateBox: {cx} {cy} {cx} {cy}");
|
||||
let _ = writeln!(s, "%AI3_TileBox: 0 {} {} {}", ps::num(below), ps::num(width), ps::num(top));
|
||||
s.push_str("%AI3_DocumentPreview: None\n");
|
||||
let _ = writeln!(s, "%AI5_ArtSize: {} {}", ps::num(width), ps::num(height));
|
||||
s.push_str("%AI5_RulerUnits: 2\n");
|
||||
let _ = writeln!(s, "%AI5_NumLayers: {layers}");
|
||||
s.push_str("%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n");
|
||||
if !w.gradients.is_empty() {
|
||||
let _ = writeln!(s, "{} Bn", w.gradients.len());
|
||||
for (name, g) in &w.gradients {
|
||||
gradient_def(&mut s, &w, name, g);
|
||||
}
|
||||
}
|
||||
s.push_str("%%EndSetup\n");
|
||||
s.push_str(&w.body);
|
||||
s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n");
|
||||
Ok(LegacyAiOutput { bytes: s.into_bytes(), warnings: w.warnings })
|
||||
}
|
||||
|
||||
/// The first line of a PostScript file (the DSC version comment).
|
||||
const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment
|
||||
|
||||
/// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops
|
||||
/// last first (`colorSpec colorStyle midPoint rampPoint %_Bs`), `BD`, `%AI5_EndGradient`.
|
||||
fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) {
|
||||
let name = ps::string(name);
|
||||
let kind = if g.kind == GradientKind::Radial { 1 } else { 0 };
|
||||
let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {kind} {} Bd\n[", g.stops.len());
|
||||
for stop in g.stops.iter().rev() {
|
||||
let paint = Paint::Solid { color: stop.color, swatch: stop.swatch.clone(), tint: stop.tint };
|
||||
match w.ink(&paint) {
|
||||
Ink::Gray(v) => {
|
||||
ps::push_num(s, f64::from(v));
|
||||
s.push_str(" 0 ");
|
||||
}
|
||||
Ink::Cmyk(c) => {
|
||||
ps::push_nums(s, &c.map(f64::from));
|
||||
s.push_str("1 ");
|
||||
}
|
||||
Ink::Rgb(c, rgb) => {
|
||||
ps::push_nums(s, &c.map(f64::from));
|
||||
ps::push_nums(s, &rgb.map(f64::from));
|
||||
s.push_str("2 ");
|
||||
}
|
||||
Ink::Spot { cmyk, name, tint } => {
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let _ = write!(s, "{} ", ps::string(&name));
|
||||
ps::push_num(s, f64::from(1.0 - tint));
|
||||
s.push_str(" 3 ");
|
||||
}
|
||||
}
|
||||
ps::push_nums(s, &[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 colour as the format writes it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Ink {
|
||||
/// Lightness (1 is white).
|
||||
Gray(f32),
|
||||
Cmyk([f32; 4]),
|
||||
/// RGB, with its CMYK equivalent.
|
||||
Rgb([f32; 4], [f32; 3]),
|
||||
/// A spot colour's full CMYK equivalent, name and tint (1: full).
|
||||
Spot {
|
||||
cmyk: [f32; 4],
|
||||
name: String,
|
||||
tint: f32,
|
||||
},
|
||||
}
|
||||
|
||||
fn unit(v: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Can `a` be drawn through (a finite, non-flat map)?
|
||||
fn invertible(a: Affine) -> bool {
|
||||
let det = a.determinant();
|
||||
det.is_finite() && det.abs() > 1e-12
|
||||
}
|
||||
|
||||
struct Writer<'a> {
|
||||
doc: &'a Document,
|
||||
/// The document → art space.
|
||||
to_art: Affine,
|
||||
cms: Arc<Cms>,
|
||||
body: String,
|
||||
/// Gradient definitions, by the name objects use.
|
||||
gradients: Vec<(String, Gradient)>,
|
||||
/// Spot colours used: name and full CMYK equivalent.
|
||||
spots: Vec<(String, [f32; 4])>,
|
||||
warnings: Vec<String>,
|
||||
/// The lock state the last `A` set.
|
||||
locked: bool,
|
||||
/// Symbols and live objects being written inside one another.
|
||||
nest: u32,
|
||||
/// Groups and layers open.
|
||||
depth: u32,
|
||||
}
|
||||
|
||||
impl<'a> Writer<'a> {
|
||||
fn new(doc: &'a Document, to_art: Affine) -> Self {
|
||||
Self {
|
||||
doc,
|
||||
to_art,
|
||||
cms: vectorcraft_color::cms::active(),
|
||||
body: String::new(),
|
||||
gradients: vec![],
|
||||
spots: vec![],
|
||||
warnings: vec![],
|
||||
locked: false,
|
||||
nest: 0,
|
||||
depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) {
|
||||
self.warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn line(&mut self, s: &str) {
|
||||
self.body.push_str(s);
|
||||
self.body.push('\n');
|
||||
}
|
||||
|
||||
// ---------- layers and objects ----------
|
||||
|
||||
/// A top-level layer. False when it isn't a layer.
|
||||
fn layer(&mut self, n: &Node) -> bool {
|
||||
let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return false };
|
||||
let [r, g, b] = match color {
|
||||
LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1),
|
||||
LayerColor::Custom(rgb) => *rgb,
|
||||
};
|
||||
let index = match color {
|
||||
LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i),
|
||||
_ => -1,
|
||||
};
|
||||
let flag = |b: bool| u8::from(b);
|
||||
// A template layer doesn't print and shows dimmed.
|
||||
let _ = writeln!(
|
||||
self.body,
|
||||
"%AI5_BeginLayer\n{} {} {} {} {} 0 {index} {r} {g} {b} Lb\n{} Ln",
|
||||
flag(n.visible),
|
||||
flag(*preview),
|
||||
flag(!n.locked),
|
||||
flag(*printable && !*template),
|
||||
flag(dim_images.is_some() || *template),
|
||||
ps::string(n.name.as_deref().unwrap_or("Layer")),
|
||||
);
|
||||
self.locked = false;
|
||||
self.line("0 A");
|
||||
self.contents(children, *clip, false);
|
||||
self.line("LB\n%AI5_EndLayer--");
|
||||
true
|
||||
}
|
||||
|
||||
/// A container's members, the first clipping the others when `clip`; `locked`: the container is.
|
||||
fn contents(&mut self, children: &[Arc<Node>], clip: bool, locked: bool) {
|
||||
if clip {
|
||||
self.clip_group(children, locked);
|
||||
} else {
|
||||
for c in children {
|
||||
self.node(c, locked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the lock state of the objects that follow.
|
||||
fn lock(&mut self, locked: bool) {
|
||||
if locked != self.locked {
|
||||
self.locked = locked;
|
||||
self.line(if locked { "1 A" } else { "0 A" });
|
||||
}
|
||||
}
|
||||
|
||||
/// One object (inside a container locked when `locked`).
|
||||
fn node(&mut self, n: &Node, locked: bool) {
|
||||
if !n.visible {
|
||||
self.warn(HIDDEN);
|
||||
return;
|
||||
}
|
||||
if self.depth >= MAX_DEPTH {
|
||||
self.warn(DEEP);
|
||||
return;
|
||||
}
|
||||
if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.extras().mask.as_ref().is_some_and(|m| !m.disabled) {
|
||||
self.warn(TRANSPARENCY);
|
||||
}
|
||||
if n.name.as_deref().is_some_and(|s| !s.is_empty()) && !n.is_layer() {
|
||||
self.warn(NAMES);
|
||||
}
|
||||
let locked = locked || n.locked;
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, clip, .. } => {
|
||||
self.warn(SUBLAYERS);
|
||||
self.group(children, *clip, locked);
|
||||
}
|
||||
NodeKind::Group { children, clip } => self.group(children, *clip, locked),
|
||||
NodeKind::Path { path, rule, guide, .. } => {
|
||||
self.lock(locked);
|
||||
if *guide {
|
||||
self.guide(path);
|
||||
} else {
|
||||
self.shape(n, path, *rule);
|
||||
}
|
||||
}
|
||||
NodeKind::Compound { children, rule } => {
|
||||
self.lock(locked);
|
||||
let mut subpaths = vec![];
|
||||
for p in children.iter().filter_map(|c| c.path_data()) {
|
||||
subpaths.extend(p.subpaths.iter().cloned());
|
||||
}
|
||||
self.shape(n, &PathData::new(subpaths), *rule);
|
||||
}
|
||||
NodeKind::Text(t) => {
|
||||
self.lock(locked);
|
||||
self.text(t);
|
||||
}
|
||||
NodeKind::Image(im) => {
|
||||
self.lock(locked);
|
||||
self.image(im);
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
self.warn(LIVE);
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
{
|
||||
let mut art = (*sym.art).clone();
|
||||
art.transform(*xf, false);
|
||||
art.visible = true;
|
||||
self.nest += 1;
|
||||
self.node(&art, locked);
|
||||
self.nest -= 1;
|
||||
}
|
||||
}
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
|
||||
self.warn(LIVE);
|
||||
if self.nest < MAX_NEST {
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
self.nest += 1;
|
||||
if let Some(children) = g.children() {
|
||||
self.group(children, false, locked);
|
||||
}
|
||||
self.nest -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A group (`u` … `U`), or a clipping group (`q` … `Q`).
|
||||
fn group(&mut self, children: &[Arc<Node>], clip: bool, locked: bool) {
|
||||
if children.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.lock(locked);
|
||||
self.depth += 1;
|
||||
if clip {
|
||||
self.clip_group(children, locked);
|
||||
} else {
|
||||
self.line("u");
|
||||
self.contents(children, false, locked);
|
||||
self.lock(locked);
|
||||
self.line("U");
|
||||
}
|
||||
self.depth -= 1;
|
||||
}
|
||||
|
||||
/// A clipping group: its first member's outline clips the others (`h W n`). That member's fills
|
||||
/// paint behind the art, inside the clip, and its strokes over it, outside.
|
||||
fn clip_group(&mut self, children: &[Arc<Node>], locked: bool) {
|
||||
let Some((clip, rest)) = children.split_first() else { return };
|
||||
// Nothing to clip by hides the clipped art.
|
||||
let Some((outline, rule)) = vectorcraft_effects::clip_outline(clip) else { return };
|
||||
let paint = clip.clip_paint();
|
||||
self.lock(locked);
|
||||
self.line("q");
|
||||
let path = PathData::from_bezpath(&outline);
|
||||
self.fill_rule(rule);
|
||||
let several = path.subpaths.len() > 1;
|
||||
if several {
|
||||
self.line("*u");
|
||||
}
|
||||
for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) {
|
||||
self.mask(sp);
|
||||
}
|
||||
if several {
|
||||
self.line("*U");
|
||||
}
|
||||
if let Some(fill) = &paint.fill {
|
||||
self.node(fill, locked);
|
||||
}
|
||||
for c in rest {
|
||||
self.node(c, locked);
|
||||
}
|
||||
self.lock(locked);
|
||||
self.line("Q");
|
||||
if let Some(stroke) = &paint.stroke {
|
||||
self.node(stroke, locked);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- paths ----------
|
||||
|
||||
/// A clipping path (§5.6.2): its points, `h` (closed) or `H`, `W`, then painted with nothing.
|
||||
fn mask(&mut self, sp: &SubPath) {
|
||||
self.subpath(sp);
|
||||
self.line(if sp.closed { "h\nW\nn" } else { "H\nW\nN" });
|
||||
}
|
||||
|
||||
/// A guide (§6): `(N) *`, its path, `(N) *`.
|
||||
fn guide(&mut self, path: &PathData) {
|
||||
for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) {
|
||||
self.line("(N) *");
|
||||
self.subpath(sp);
|
||||
self.line("(N) *");
|
||||
}
|
||||
}
|
||||
|
||||
/// A path's fills and strokes: one object with one fill and a plain stroke over it where it
|
||||
/// can be, else a group of an object for each (in paint order).
|
||||
fn shape(&mut self, n: &Node, path: &PathData, rule: FillRule) {
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
let items: Vec<&AppearanceItem> = n
|
||||
.appearance
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| match i {
|
||||
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(),
|
||||
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && s.width.is_finite(),
|
||||
})
|
||||
.collect();
|
||||
for i in &items {
|
||||
if i.opacity() < 1.0 || i.blend() != BlendMode::Normal {
|
||||
self.warn(TRANSPARENCY);
|
||||
}
|
||||
}
|
||||
match items.as_slice() {
|
||||
[] => self.path_object(path, rule, None, None),
|
||||
[AppearanceItem::Fill(f)] if direct_fill(&f.paint) => self.path_object(path, rule, Some(f), None),
|
||||
[AppearanceItem::Stroke(s)] if direct_stroke(s) => self.path_object(path, rule, None, Some(s)),
|
||||
[AppearanceItem::Fill(f), AppearanceItem::Stroke(s)] if direct_fill(&f.paint) && direct_stroke(s) => {
|
||||
self.path_object(path, rule, Some(f), Some(s))
|
||||
}
|
||||
[one] => self.item(path, rule, one),
|
||||
items => {
|
||||
self.depth += 1;
|
||||
self.line("u");
|
||||
for i in items {
|
||||
self.item(path, rule, i);
|
||||
}
|
||||
self.line("U");
|
||||
self.depth -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One fill or stroke of a path as an object of its own.
|
||||
fn item(&mut self, path: &PathData, rule: FillRule, item: &AppearanceItem) {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => self.fill_object(path, rule, f),
|
||||
AppearanceItem::Stroke(s) if direct_stroke(s) => self.path_object(path, rule, None, Some(s)),
|
||||
AppearanceItem::Stroke(s) => {
|
||||
self.warn(STROKES);
|
||||
let outline = stroke::outline_region(path, rule, s);
|
||||
let fill = FillLayer { overprint: s.overprint, ..FillLayer::new(s.paint.clone()) };
|
||||
self.fill_object(&outline, FillRule::NonZero, &fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fill as an object of its own: patterns as their tiles clipped to the path.
|
||||
fn fill_object(&mut self, path: &PathData, rule: FillRule, f: &FillLayer) {
|
||||
if direct_fill(&f.paint) {
|
||||
return self.path_object(path, rule, Some(f), None);
|
||||
}
|
||||
let Paint::Pattern { pattern, xf } = &f.paint else {
|
||||
// A freeform gradient: its average colour.
|
||||
self.warn(FREEFORM);
|
||||
let color = match &f.paint {
|
||||
Paint::Gradient(g) => average(&g.gradient),
|
||||
_ => Color::gray(0.5),
|
||||
};
|
||||
let flat = FillLayer { paint: Paint::solid(color), ..f.clone() };
|
||||
return self.path_object(path, rule, Some(&flat), None);
|
||||
};
|
||||
self.warn(PATTERNS);
|
||||
let doc = self.doc;
|
||||
let Some(def) = doc.pattern(pattern) else {
|
||||
let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() };
|
||||
return self.path_object(path, rule, Some(&flat), None);
|
||||
};
|
||||
let area = path.to_bezpath().bounding_box();
|
||||
let tiles = def.instances_in(*xf, area);
|
||||
self.line("q");
|
||||
self.fill_rule(rule);
|
||||
let several = path.subpaths.len() > 1;
|
||||
if several {
|
||||
self.line("*u");
|
||||
}
|
||||
for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) {
|
||||
self.mask(sp);
|
||||
}
|
||||
if several {
|
||||
self.line("*U");
|
||||
}
|
||||
let locked = self.locked;
|
||||
for t in &tiles {
|
||||
self.node(t, locked);
|
||||
}
|
||||
self.lock(locked);
|
||||
self.line("Q");
|
||||
}
|
||||
|
||||
/// A path object painted with `fill` (a colour or a linear or radial gradient) and `stroke`
|
||||
/// (a plain line of a colour) over it; several subpaths make a compound path.
|
||||
fn path_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) {
|
||||
let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect();
|
||||
if subpaths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let gradient = match fill.map(|f| &f.paint) {
|
||||
Some(Paint::Gradient(g)) => Some(self.gradient(g, path.to_bezpath().bounding_box())),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(f) = fill {
|
||||
let paint = match &f.paint {
|
||||
// The colour under the gradient: its first stop's.
|
||||
Paint::Gradient(g) => g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color)),
|
||||
p => p.clone(),
|
||||
};
|
||||
self.color(&paint, false);
|
||||
self.line(if f.overprint { "1 O" } else { "0 O" });
|
||||
}
|
||||
if let Some(s) = stroke {
|
||||
self.color(&s.paint, true);
|
||||
self.line(if s.overprint { "1 R" } else { "0 R" });
|
||||
self.line_style(s);
|
||||
}
|
||||
self.fill_rule(rule);
|
||||
let op = match (fill.is_some(), stroke.is_some()) {
|
||||
(true, true) => 'b',
|
||||
(true, false) => 'f',
|
||||
(false, true) => 's',
|
||||
(false, false) => 'n',
|
||||
};
|
||||
let several = subpaths.len() > 1;
|
||||
if several {
|
||||
self.line("*u");
|
||||
}
|
||||
for sp in subpaths {
|
||||
self.subpath(sp);
|
||||
if let Some(g) = &gradient {
|
||||
// The gradient fills between `Bb` and `BB`, whose flag strokes the path too (§3.5.3).
|
||||
self.body.push_str(g);
|
||||
self.line(if sp.closed { "f" } else { "F" });
|
||||
self.line(match (stroke.is_some(), sp.closed) {
|
||||
(false, _) => "0 BB",
|
||||
(true, false) => "1 BB",
|
||||
(true, true) => "2 BB",
|
||||
});
|
||||
} else {
|
||||
self.line(&if sp.closed { op.to_string() } else { op.to_ascii_uppercase().to_string() });
|
||||
}
|
||||
}
|
||||
if several {
|
||||
self.line("*U");
|
||||
}
|
||||
}
|
||||
|
||||
/// `rule` as `XR` (1: even-odd).
|
||||
fn fill_rule(&mut self, rule: FillRule) {
|
||||
self.line(if rule == FillRule::EvenOdd { "1 XR" } else { "0 XR" });
|
||||
}
|
||||
|
||||
/// One subpath's points, in art space: `m`, then a line or curve to each point (lower case: the
|
||||
/// point is smooth, upper case: a corner, §5.3.2), and for a closed one the segment back to the
|
||||
/// start, which says whether the start is smooth or a corner (as the specification's examples
|
||||
/// close paths: the start point again, then the painting operator).
|
||||
fn subpath(&mut self, sp: &SubPath) {
|
||||
let Some(first) = sp.anchors.first() else { return };
|
||||
let xf = self.to_art;
|
||||
let s = &mut self.body;
|
||||
let pt = |s: &mut String, p: Point| {
|
||||
let p = xf * p;
|
||||
ps::push_nums(s, &[p.x, p.y]);
|
||||
};
|
||||
pt(s, first.p);
|
||||
s.push_str("m\n");
|
||||
let segment = |s: &mut String, a: &vectorcraft_geom::Anchor, b: &vectorcraft_geom::Anchor| {
|
||||
let straight = a.h_out == a.p && b.h_in == b.p;
|
||||
let smooth = b.kind == AnchorKind::Smooth;
|
||||
if straight {
|
||||
pt(s, b.p);
|
||||
s.push_str(if smooth { "l\n" } else { "L\n" });
|
||||
} else {
|
||||
pt(s, a.h_out);
|
||||
pt(s, b.h_in);
|
||||
pt(s, b.p);
|
||||
s.push_str(if smooth { "c\n" } else { "C\n" });
|
||||
}
|
||||
};
|
||||
for pair in sp.anchors.windows(2) {
|
||||
if let [a, b] = pair {
|
||||
segment(s, a, b);
|
||||
}
|
||||
}
|
||||
if sp.closed
|
||||
&& sp.anchors.len() > 1
|
||||
&& let Some(last) = sp.anchors.last()
|
||||
{
|
||||
segment(s, last, first);
|
||||
}
|
||||
}
|
||||
|
||||
/// The stroke's width, cap, join, miter limit and dashes: `w J j M d`.
|
||||
fn line_style(&mut self, st: &StrokeLayer) {
|
||||
let cap = match st.cap {
|
||||
LineCap::Butt => 0,
|
||||
LineCap::Round => 1,
|
||||
LineCap::Square => 2,
|
||||
};
|
||||
let join = match st.join {
|
||||
LineJoin::Miter => 0,
|
||||
LineJoin::Round => 1,
|
||||
LineJoin::Bevel => 2,
|
||||
};
|
||||
let s = &mut self.body;
|
||||
ps::push_num(s, st.width);
|
||||
let _ = write!(s, " w {cap} J {join} j ");
|
||||
ps::push_num(s, if st.miter_limit.is_finite() { st.miter_limit.max(1.0) } else { 10.0 });
|
||||
s.push_str(" M [");
|
||||
let dash = st.dash.as_ref().filter(|d| d.is_dashed());
|
||||
if let Some(d) = dash {
|
||||
ps::push_nums(s, &d.pattern);
|
||||
s.pop();
|
||||
}
|
||||
s.push_str("] ");
|
||||
ps::push_num(s, dash.map_or(0.0, |d| d.offset));
|
||||
s.push_str(" d\n");
|
||||
}
|
||||
|
||||
// ---------- colour ----------
|
||||
|
||||
/// How `p` (a solid paint) is written.
|
||||
fn ink(&self, p: &Paint) -> Ink {
|
||||
let Paint::Solid { color, swatch, tint } = p else { return Ink::Gray(0.0) };
|
||||
let doc = self.doc;
|
||||
let intent = self.cms.settings().intent;
|
||||
if let Some(name) = swatch
|
||||
&& let Some(sw) = doc.swatch(name).filter(|s| s.spot)
|
||||
&& let Some(full) = sw.paint.color()
|
||||
{
|
||||
let full = doc.linked_color(full, true);
|
||||
return Ink::Spot { cmyk: self.cms.to_cmyk(&full, intent).map(unit), name: name.clone(), tint: unit(*tint) };
|
||||
}
|
||||
match *color {
|
||||
// Grey is ink coverage; the format's grey is lightness.
|
||||
Color::Gray { k } => Ink::Gray(unit(1.0 - k)),
|
||||
Color::Cmyk { c, m, y, k } => Ink::Cmyk([c, m, y, k].map(unit)),
|
||||
Color::Rgb { r, g, b } => Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), [r, g, b].map(unit)),
|
||||
Color::Lab { .. } => {
|
||||
let rgb = color.to_rgb().map(unit);
|
||||
Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), rgb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the fill (or with `stroke`, the stroke) colour to solid paint `p`.
|
||||
fn color(&mut self, p: &Paint, stroke: bool) {
|
||||
let ink = self.ink(p);
|
||||
let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill };
|
||||
let s = &mut self.body;
|
||||
match ink {
|
||||
Ink::Gray(v) => {
|
||||
ps::push_num(s, f64::from(v));
|
||||
let _ = writeln!(s, " {}", op("g", "G"));
|
||||
}
|
||||
Ink::Cmyk(c) => {
|
||||
ps::push_nums(s, &c.map(f64::from));
|
||||
let _ = writeln!(s, "{}", op("k", "K"));
|
||||
}
|
||||
Ink::Rgb(_, rgb) => {
|
||||
ps::push_nums(s, &rgb.map(f64::from));
|
||||
let _ = writeln!(s, "{}", op("Xa", "XA"));
|
||||
}
|
||||
Ink::Spot { cmyk, name, tint } => {
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let _ = write!(s, "{} ", ps::string(&name));
|
||||
// The format's tint is how much lighter than the full colour (0: full).
|
||||
ps::push_num(s, f64::from(1.0 - tint));
|
||||
let _ = writeln!(s, " {}", op("x", "X"));
|
||||
if !self.spots.iter().any(|(n, _)| *n == name) {
|
||||
self.spots.push((name, cmyk));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- gradients ----------
|
||||
|
||||
/// The definition gradient `g` uses (defined once for each distinct gradient), and its
|
||||
/// instance on a path of bounds `bounds`: `Bb`, `1 (name) x y angle length 1 0 0 1 0 0 Bg`, a
|
||||
/// radial's highlight `x y angle fraction Bh` (its distance a fraction of the radius, §3.5.5).
|
||||
fn gradient(&mut self, g: &GradientPaint, bounds: Rect) -> String {
|
||||
let name = self.define(&g.gradient, g.swatch.as_deref());
|
||||
for stop in &g.gradient.stops {
|
||||
if stop.opacity < 1.0 {
|
||||
self.warn(TRANSPARENCY);
|
||||
}
|
||||
if let Some(sw) = &stop.swatch {
|
||||
// Spot colours in stops are custom colours too.
|
||||
let p = Paint::Solid { color: stop.color, swatch: Some(sw.clone()), tint: stop.tint };
|
||||
if let Ink::Spot { cmyk, name, .. } = self.ink(&p)
|
||||
&& !self.spots.iter().any(|(n, _)| *n == name)
|
||||
{
|
||||
self.spots.push((name, cmyk));
|
||||
}
|
||||
}
|
||||
}
|
||||
let geom = g.resolve(bounds);
|
||||
let (start, end) = (self.to_art * geom.start, self.to_art * geom.end);
|
||||
let d = end - start;
|
||||
let (angle, length) = (d.y.atan2(d.x).to_degrees(), d.hypot());
|
||||
// The matrix is relative to the origin of a canvas whose size the specification doesn't
|
||||
// give (§3.5.4), so it stays the identity: an elliptical radial is written circular.
|
||||
if g.gradient.kind == GradientKind::Radial && (geom.aspect - 1.0).abs() > 1e-6 {
|
||||
self.warn(ELLIPTICAL);
|
||||
}
|
||||
let mut s = String::from("Bb\n");
|
||||
if g.gradient.kind == GradientKind::Radial
|
||||
&& let Some(f) = geom.focal
|
||||
{
|
||||
let off: Vec2 = self.to_art * f - start;
|
||||
if off.hypot() > 1e-9 && length > 1e-9 {
|
||||
let fraction = (off.hypot() / length).min(1.0);
|
||||
ps::push_nums(&mut s, &[off.x, off.y, off.y.atan2(off.x).to_degrees(), fraction]);
|
||||
s.push_str("Bh\n");
|
||||
}
|
||||
}
|
||||
s.push_str("1 ");
|
||||
s.push_str(&ps::string(&name));
|
||||
s.push(' ');
|
||||
ps::push_nums(&mut s, &[start.x, start.y, angle, length]);
|
||||
s.push_str("1 0 0 1 0 0 Bg\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// The name of the definition of `g` (its swatch's name when it has one), defined the first
|
||||
/// time.
|
||||
fn define(&mut self, g: &Gradient, swatch: Option<&str>) -> String {
|
||||
if let Some((name, _)) = self.gradients.iter().find(|(_, d)| d == g) {
|
||||
return name.clone();
|
||||
}
|
||||
let taken = |n: &str, defs: &[(String, Gradient)]| defs.iter().any(|(m, _)| m == n);
|
||||
let base = swatch.filter(|s| !s.is_empty()).unwrap_or("Gradient").to_string();
|
||||
let mut name = base.clone();
|
||||
let mut i = 1;
|
||||
while taken(&name, &self.gradients) {
|
||||
i += 1;
|
||||
name = format!("{base} {i}");
|
||||
}
|
||||
self.gradients.push((name.clone(), g.clone()));
|
||||
name
|
||||
}
|
||||
|
||||
// ---------- type ----------
|
||||
|
||||
/// Type as its glyphs' outlines: a group of a compound path for each run, with the run's fill
|
||||
/// and stroke.
|
||||
fn text(&mut self, t: &TextObject) {
|
||||
self.warn(TYPE);
|
||||
if !invertible(t.xf) {
|
||||
return;
|
||||
}
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let scale = t.xf.determinant().abs().sqrt();
|
||||
self.depth += 1;
|
||||
self.line("u");
|
||||
for (i, run) in t.runs.iter().enumerate() {
|
||||
let mut bp = BezPath::new();
|
||||
for g in layout.glyphs.iter().filter(|g| g.run == i) {
|
||||
bp.extend(g.outline.iter());
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
bp.apply_affine(t.xf);
|
||||
let path = PathData::from_bezpath(&bp);
|
||||
let fill =
|
||||
(!run.style.fill.is_none()).then(|| FillLayer { overprint: run.style.overprint_fill, ..FillLayer::new(run.style.fill.clone()) });
|
||||
let stroke = run.style.has_stroke().then(|| {
|
||||
let mut s = run.style.stroke_layer();
|
||||
s.width *= scale;
|
||||
s
|
||||
});
|
||||
let items: Vec<AppearanceItem> = fill.map(AppearanceItem::Fill).into_iter().chain(stroke.map(AppearanceItem::Stroke)).collect();
|
||||
let mut n = Node::path(vectorcraft_doc::NodeId(0), path.clone(), vectorcraft_doc::Appearance::default());
|
||||
n.appearance = vectorcraft_doc::Appearance { items, ..vectorcraft_doc::Appearance::default() }.into();
|
||||
self.shape(&n, &path, FillRule::NonZero);
|
||||
}
|
||||
self.line("U");
|
||||
self.depth -= 1;
|
||||
}
|
||||
|
||||
// ---------- images ----------
|
||||
|
||||
/// An image (§8): `%AI5_BeginRaster`, its matrix (pixels, y down, → art space with y flipped)
|
||||
/// and operands with `XI`, its RGB samples in hex on comment lines, `%AI5_EndRaster`.
|
||||
fn image(&mut self, im: &ImageObject) {
|
||||
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
let m = flip * self.to_art * im.xf;
|
||||
if !invertible(m) {
|
||||
return;
|
||||
}
|
||||
let Some(img) = self.doc.images.get(&im.key).and_then(|b| image::load_from_memory(&b.bytes).ok()) else {
|
||||
self.warn(UNREADABLE_IMAGE);
|
||||
return;
|
||||
};
|
||||
let (w, h) = (img.width(), img.height());
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS {
|
||||
self.warn(UNREADABLE_IMAGE);
|
||||
return;
|
||||
}
|
||||
// The object places the image's own pixel grid (its width × height) wherever the file's is.
|
||||
let fit = Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(w), f64::from(im.height.max(1)) / f64::from(h));
|
||||
let m = flip * self.to_art * im.xf * fit;
|
||||
let rgba = img.to_rgba8();
|
||||
let mut hex = String::with_capacity(rgba.len() / 4 * 7);
|
||||
hex.push('%');
|
||||
let mut on_line = 0;
|
||||
let mut see_through = false;
|
||||
for px in rgba.pixels() {
|
||||
let [r, g, b, a] = px.0;
|
||||
if a < 255 {
|
||||
see_through = true;
|
||||
}
|
||||
// Over white.
|
||||
let over = |c: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8;
|
||||
for c in [over(r), over(g), over(b)] {
|
||||
let _ = write!(hex, "{c:02X}");
|
||||
on_line += 2;
|
||||
if on_line >= HEX_LINE {
|
||||
hex.push_str("\n%");
|
||||
on_line = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if hex.ends_with('%') {
|
||||
hex.pop();
|
||||
} else {
|
||||
hex.push('\n');
|
||||
}
|
||||
if see_through {
|
||||
self.warn(IMAGE_ALPHA);
|
||||
}
|
||||
let [a, b, c, d, e, f] = m.as_coeffs();
|
||||
let s = &mut self.body;
|
||||
s.push_str("%AI5_File:\n%AI5_BeginRaster\n[");
|
||||
ps::push_nums(s, &[a, b, c, d, e, -f]);
|
||||
s.pop();
|
||||
let _ = writeln!(s, "] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI");
|
||||
s.push_str(&hex);
|
||||
s.push_str("%AI5_EndRaster\n");
|
||||
}
|
||||
}
|
||||
|
||||
/// Can `p` paint a path object's fill directly (a colour or a linear or radial gradient)?
|
||||
fn direct_fill(p: &Paint) -> bool {
|
||||
match p {
|
||||
Paint::Solid { .. } => true,
|
||||
Paint::Gradient(g) => g.gradient.kind != GradientKind::Freeform && !g.gradient.stops.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Can stroke `s` be written as the stroke of a path object (a plain line of a colour)?
|
||||
fn direct_stroke(s: &StrokeLayer) -> bool {
|
||||
matches!(s.paint, Paint::Solid { .. }) && stroke::is_plain(s)
|
||||
}
|
||||
|
||||
/// The average colour of a gradient's stops (in RGB).
|
||||
fn average(g: &Gradient) -> Color {
|
||||
let n = g.stops.len().max(1) as f32;
|
||||
let sum = g.stops.iter().map(|s| s.color.to_rgb()).fold([0.0f32; 3], |a, c| [a[0] + c[0], a[1] + c[1], a[2] + c[2]]);
|
||||
Color::rgb(unit(sum[0] / n), unit(sum[1] / n), unit(sum[2] / n))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests_legacy_ai.rs"]
|
||||
mod tests;
|
||||
@@ -27,14 +27,17 @@
|
||||
//! - `tiff`: the TIFF preview.
|
||||
//! - `import`: the PostScript interpreter.
|
||||
//! - `print`: print jobs as PostScript files ([`print()`]), their pages drawn by `scene`.
|
||||
//! - `legacy_ai`: legacy `.ai` files of version 7 ([`export_legacy_ai`]), with their layers and groups.
|
||||
|
||||
mod import;
|
||||
mod legacy_ai;
|
||||
mod print;
|
||||
mod ps;
|
||||
mod scene;
|
||||
mod tiff;
|
||||
|
||||
pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai};
|
||||
pub use legacy_ai::{LegacyAiOptions, LegacyAiOutput, export_legacy_ai};
|
||||
pub use print::{PrintJob, PrintPage, print};
|
||||
|
||||
use vectorcraft_doc::Document;
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Legacy `.ai` files (version 7) written, then read back as File › Open reads them.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, AnchorKind, FillRule, PathData, Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A node of `doc` filled `fill` (and stroked `stroke` 2 pt wide), path `path`.
|
||||
fn shape(doc: &mut Document, path: PathData, fill: Paint, stroke: Paint) -> Node {
|
||||
Node::path(doc.alloc_id(), path, Appearance::basic(fill, stroke, 2.0))
|
||||
}
|
||||
|
||||
fn red() -> Paint {
|
||||
Paint::solid(Color::rgb(1.0, 0.0, 0.0))
|
||||
}
|
||||
|
||||
/// A 200 × 100 document: a layer `Back` holding a group of a red square (stroked blue) and a
|
||||
/// locked one, a compound ring, a clipping group and a gradient square; a hidden layer `Front`
|
||||
/// holding an ellipse and a 2 × 1 image.
|
||||
fn sample() -> Document {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
doc.layers.clear();
|
||||
let back = doc.add_layer(Some("Back"));
|
||||
let front = doc.add_layer(Some("Front"));
|
||||
let a = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), red(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||||
let mut b = shape(&mut doc, shapes::rectangle(Rect::new(50.0, 10.0, 80.0, 40.0)), Paint::solid(Color::gray(0.5)), Paint::None);
|
||||
b.locked = true;
|
||||
let group = Node::group(doc.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
|
||||
doc.insert(Some(back), usize::MAX, group).unwrap();
|
||||
let outer = shape(&mut doc, shapes::rectangle(Rect::new(100.0, 10.0, 140.0, 50.0)), Paint::None, Paint::None);
|
||||
let inner = shape(&mut doc, shapes::rectangle(Rect::new(110.0, 20.0, 130.0, 40.0)), Paint::None, Paint::None);
|
||||
let mut ring = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd });
|
||||
ring.appearance = Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), Paint::None, 0.0).into();
|
||||
doc.insert(Some(back), usize::MAX, ring).unwrap();
|
||||
let clip = Node::new(
|
||||
doc.alloc_id(),
|
||||
NodeKind::Path {
|
||||
path: shapes::rectangle(Rect::new(160.0, 20.0, 180.0, 40.0)),
|
||||
rule: FillRule::NonZero,
|
||||
live: None,
|
||||
clipping: true,
|
||||
guide: false,
|
||||
},
|
||||
);
|
||||
let clipped = shape(&mut doc, shapes::rectangle(Rect::new(150.0, 10.0, 190.0, 50.0)), Paint::solid(Color::gray(0.25)), Paint::None);
|
||||
let window = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(clipped)], clip: true });
|
||||
doc.insert(Some(back), usize::MAX, window).unwrap();
|
||||
let g = Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops: 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 shaded = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 60.0, 50.0, 90.0)), Paint::Gradient(Box::new(GradientPaint::new(g))), Paint::None);
|
||||
doc.insert(Some(back), usize::MAX, shaded).unwrap();
|
||||
let ellipse = shape(&mut doc, shapes::ellipse(Rect::new(100.0, 60.0, 160.0, 90.0)), red(), Paint::None);
|
||||
doc.insert(Some(front), usize::MAX, ellipse).unwrap();
|
||||
let mut png = vec![];
|
||||
let img = image::RgbaImage::from_raw(2, 1, vec![255, 0, 0, 255, 0, 0, 255, 255]).unwrap();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let blob = ImageBlob::new("image/png", png);
|
||||
let key = blob.content_key();
|
||||
doc.images.insert(key.clone(), blob);
|
||||
let im =
|
||||
ImageObject { key, width: 2, height: 1, xf: Affine::new([10.0, 0.0, 0.0, 10.0, 170.0, 70.0]), link: None, placement: Default::default() };
|
||||
let im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
doc.insert(Some(front), usize::MAX, im).unwrap();
|
||||
if let Some(l) = doc.layers.get_mut(1) {
|
||||
Arc::make_mut(l).visible = false;
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
fn write(doc: &Document) -> LegacyAiOutput {
|
||||
export_legacy_ai(doc, &LegacyAiOptions { title: "Sample".into(), created: Some(0) }).unwrap()
|
||||
}
|
||||
|
||||
fn children(n: &Node) -> &[Arc<Node>] {
|
||||
n.children().map_or(&[][..], Vec::as_slice)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_the_documented_operators() {
|
||||
let out = write(&sample());
|
||||
let text = String::from_utf8(out.bytes).unwrap();
|
||||
for needle in [
|
||||
"%!PS-Adobe-3.0\n",
|
||||
"%AI5_FileFormat 3\n",
|
||||
"%%CreationDate: (1/1/1970) (00:00)\n",
|
||||
"%AI5_ArtSize: 200 100",
|
||||
"%AI3_TemplateBox: 100 50 100 50",
|
||||
"%AI5_NumLayers: 2",
|
||||
"(Back) Ln",
|
||||
"(Front) Ln",
|
||||
"%AI5_BeginGradient: (Gradient)",
|
||||
"\nu\n",
|
||||
"\n*u\n",
|
||||
"\nq\n",
|
||||
"\n1 A\n",
|
||||
"XI\n",
|
||||
"%%EOF\n",
|
||||
] {
|
||||
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
|
||||
}
|
||||
// The hidden layer is written hidden: visible 0.
|
||||
assert!(text.contains("%AI5_BeginLayer\n0 1 1 1 0 0 1 "), "{text}");
|
||||
// Art space is y up from the artboard's bottom-left: the red square (10..40 down from the top)
|
||||
// is 60..90 up. Its corners are corner points (upper case, AI7 spec §5.3.2); its fill RGB
|
||||
// (`r g b Xa`, §5.4.1).
|
||||
assert!(text.contains("1 0 0 Xa\n"), "{text}");
|
||||
assert!(text.contains("10 90 m\n40 90 L\n40 60 L\n10 60 L\n10 90 L\nb\n"), "{text}");
|
||||
// The ellipse's points are smooth (lower case).
|
||||
assert!(text.contains(" c\n") && !text.contains(" C\n"), "{text}");
|
||||
// The clipping path: closed by `h`, then `W n` (§5.6.2).
|
||||
assert!(text.contains(" L\nh\nW\nn\n"), "{text}");
|
||||
// Gradients: counted (`Bn`), each stop's operands before `%_Bs` (§3.5).
|
||||
assert!(text.contains("%%BeginSetup\n1 Bn\n%AI5_BeginGradient: (Gradient)\n(Gradient) 0 2 Bd\n[\n"), "{text}");
|
||||
assert!(text.contains(" 2 50 100 %_Bs\n") && !text.contains("Np\n"), "{text}");
|
||||
assert!(text.contains("1 0 0 1 0 0 Bg\nf\n0 BB\n"), "{text}");
|
||||
// The image: operands and `XI` on a line, its samples on comment lines (§8).
|
||||
assert!(text.contains("] 0 0 2 1 2 1 8 3 0 0 0 0 XI\n%FF00000000FF\n%AI5_EndRaster\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_fill_and_its_stroke_are_one_object() {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
let g = Gradient {
|
||||
kind: GradientKind::Radial,
|
||||
stops: vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 1.0))],
|
||||
};
|
||||
let mut paint = GradientPaint::new(g);
|
||||
paint.geom = Some(vectorcraft_color::GradientGeom {
|
||||
start: vectorcraft_geom::Point::new(30.0, 30.0),
|
||||
end: vectorcraft_geom::Point::new(50.0, 30.0),
|
||||
aspect: 1.0,
|
||||
focal: Some(vectorcraft_geom::Point::new(35.0, 30.0)),
|
||||
});
|
||||
let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), Paint::Gradient(Box::new(paint)), red());
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let text = String::from_utf8(write(&doc).bytes).unwrap();
|
||||
// `2 BB` strokes the filled path (§3.5.3); the highlight is a quarter of the radius out (§3.5.5).
|
||||
assert!(text.contains("Bb\n5 0 0 0.25 Bh\n1 (Gradient) 30 70 0 20 1 0 0 1 0 0 Bg\nf\n2 BB\n"), "{text}");
|
||||
let doc = crate::import(text.as_bytes()).unwrap().document;
|
||||
let n = &children(&doc.layers[0])[0];
|
||||
let Paint::Gradient(g) = n.appearance.fill_paint() else { panic!("{:?}", n.appearance) };
|
||||
assert_eq!(g.gradient.kind, GradientKind::Radial);
|
||||
assert_eq!(n.appearance.stroke_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_back_with_layers_groups_and_paths() {
|
||||
let out = write(&sample());
|
||||
let imported = crate::import(&out.bytes).unwrap();
|
||||
let doc = imported.document.clone();
|
||||
let names: Vec<_> = doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names, ["Back", "Front"], "{:?}", imported.warnings);
|
||||
assert!(doc.layers[0].visible);
|
||||
assert!(!doc.layers[1].visible);
|
||||
let back = children(&doc.layers[0]);
|
||||
assert_eq!(back.len(), 4, "{back:#?}");
|
||||
// The group of two squares, the second locked.
|
||||
let NodeKind::Group { children: pair, clip: false } = &back[0].kind else { panic!("{:?}", back[0].kind) };
|
||||
assert_eq!(pair.len(), 2);
|
||||
assert!(!pair[0].locked && pair[1].locked);
|
||||
let a = &pair[0];
|
||||
assert_eq!(a.appearance.fill_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert_eq!(a.appearance.stroke_paint().color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
// Four corners: the start point written again to close it isn't a fifth.
|
||||
let sq = a.path_data().unwrap();
|
||||
assert!(sq.subpaths[0].closed && sq.subpaths[0].anchors.len() == 4, "{sq:?}");
|
||||
assert!(sq.subpaths[0].anchors.iter().all(|p| p.kind == AnchorKind::Corner), "{sq:?}");
|
||||
let bounds = a.visual_bounds().unwrap();
|
||||
assert!((bounds.x0 - 9.0).abs() < 0.01 && (bounds.y0 - 9.0).abs() < 0.01, "{bounds:?}");
|
||||
// The ring: a compound path, even-odd.
|
||||
assert!(matches!(&back[1].kind, NodeKind::Compound { children, rule: FillRule::EvenOdd } if children.len() == 2), "{:?}", back[1].kind);
|
||||
// The clipping group: its clipping path first.
|
||||
let NodeKind::Group { children: window, clip: true } = &back[2].kind else { panic!("{:?}", back[2].kind) };
|
||||
assert!(matches!(window[0].kind, NodeKind::Path { clipping: true, .. }));
|
||||
// The gradient square.
|
||||
let Paint::Gradient(g) = back[3].appearance.fill_paint() else { panic!("{:?}", back[3].appearance) };
|
||||
assert_eq!(g.gradient.kind, GradientKind::Linear);
|
||||
assert_eq!(g.gradient.stops.len(), 2);
|
||||
assert_eq!(g.gradient.stops[0].color, Color::rgb(1.0, 1.0, 1.0));
|
||||
// The hidden layer: the ellipse with smooth points, and the image.
|
||||
let front = children(&doc.layers[1]);
|
||||
assert_eq!(front.len(), 2, "{:?} {:?}", front.iter().map(|n| &n.kind).collect::<Vec<_>>(), imported.warnings);
|
||||
let path = front[0].path_data().unwrap();
|
||||
assert_eq!(path.subpaths.len(), 1);
|
||||
assert!(path.subpaths[0].closed);
|
||||
assert!(path.subpaths[0].anchors.iter().all(|a| a.kind == AnchorKind::Smooth), "{path:?}");
|
||||
assert_eq!(path.subpaths[0].anchors.len(), 4, "{path:?}");
|
||||
let NodeKind::Image(im) = &front[1].kind else { panic!("{:?}", front[1].kind) };
|
||||
assert_eq!((im.width, im.height), (2, 1));
|
||||
let b = front[1].visual_bounds().unwrap();
|
||||
assert!((b.x0 - 170.0).abs() < 0.01 && (b.y0 - 70.0).abs() < 0.01 && (b.x1 - 190.0).abs() < 0.01, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_colours_are_custom_colours() {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
doc.swatches.push(vectorcraft_color::Swatch {
|
||||
name: "Spot Test".into(),
|
||||
paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)),
|
||||
global: true,
|
||||
spot: true,
|
||||
});
|
||||
let fill = Paint::Solid { color: Color::cmyk(0.0, 0.25, 0.5, 0.0), swatch: Some("Spot Test".into()), tint: 0.5 };
|
||||
let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), fill, Paint::None);
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = write(&doc);
|
||||
let text = String::from_utf8(out.bytes.clone()).unwrap();
|
||||
assert!(text.contains("%%DocumentCustomColors: (Spot Test)"), "{text}");
|
||||
assert!(text.contains("0 0.5 1 0 (Spot Test) 0.5 x"), "{text}");
|
||||
let doc = crate::import(&out.bytes).unwrap().document;
|
||||
let n = &children(&doc.layers[0])[0];
|
||||
let Paint::Solid { swatch, tint, .. } = n.appearance.fill_paint() else { panic!() };
|
||||
assert_eq!(swatch.as_deref(), Some("Spot Test"));
|
||||
assert!((tint - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losses_are_warned() {
|
||||
let mut doc = sample();
|
||||
// A sublayer, a hidden object, a named object, type and half opacity.
|
||||
let back = doc.layers[0].id;
|
||||
let sub = Node::layer(doc.alloc_id(), "Sub", vectorcraft_doc::LayerColor::Preset(2));
|
||||
let sub = doc.insert(Some(back), usize::MAX, sub).unwrap();
|
||||
let mut hidden = shape(&mut doc, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), red(), Paint::None);
|
||||
hidden.visible = false;
|
||||
doc.insert(Some(sub), usize::MAX, hidden).unwrap();
|
||||
let mut named = shape(&mut doc, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), red(), Paint::None);
|
||||
named.name = Some("Named".into());
|
||||
named.opacity = 0.5;
|
||||
doc.insert(Some(sub), usize::MAX, named).unwrap();
|
||||
let w = write(&doc).warnings;
|
||||
for expected in [SUBLAYERS, HIDDEN, NAMES, TRANSPARENCY] {
|
||||
assert!(w.iter().any(|x| x == expected), "{expected} not in {w:?}");
|
||||
}
|
||||
// The sublayer is a group in its layer.
|
||||
let doc = crate::import(&write(&doc).bytes).unwrap().document;
|
||||
let last = children(&doc.layers[0]).last().unwrap();
|
||||
assert!(matches!(&last.kind, NodeKind::Group { children, .. } if children.len() == 1), "{:?}", last.kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_fills_and_odd_strokes_become_objects() {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
let mut n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), red(), Paint::solid(Color::gray(1.0)));
|
||||
let mut a = (*n.appearance).clone();
|
||||
if let Some(AppearanceItem::Stroke(s)) = a.items.iter_mut().find(|i| !i.is_fill()) {
|
||||
s.align = vectorcraft_doc::StrokeAlign::Inside;
|
||||
}
|
||||
n.appearance = a.into();
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = write(&doc);
|
||||
assert!(out.warnings.iter().any(|w| w.contains("filled outlines")), "{:?}", out.warnings);
|
||||
let doc = crate::import(&out.bytes).unwrap().document;
|
||||
let n = &children(&doc.layers[0])[0];
|
||||
// The fill, then the stroke's outline filled.
|
||||
assert!(matches!(&n.kind, NodeKind::Group { children, .. } if children.len() == 2), "{:?}", n.kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_stays_put_on_an_artboard_a_fraction_of_a_point_tall() {
|
||||
let mut doc = Document::new(100.0, 364.252);
|
||||
let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 20.0, 30.0, 40.0)), red(), Paint::None);
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let text = String::from_utf8(write(&doc).bytes).unwrap();
|
||||
// The artboard's top is on a whole point (365); the template box's centre is the artboard's.
|
||||
assert!(text.contains("%AI3_TemplateBox: 50 182.874 50 182.874\n"), "{text}");
|
||||
assert!(text.contains("10 345 m\n"), "{text}");
|
||||
let doc = crate::import(text.as_bytes()).unwrap().document;
|
||||
let b = children(&doc.layers[0])[0].visual_bounds().unwrap();
|
||||
assert!((b.x0 - 10.0).abs() < 1e-3 && (b.y0 - 20.0).abs() < 1e-3 && (b.y1 - 40.0).abs() < 1e-3, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_an_artboard() {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
doc.artboards.clear();
|
||||
assert!(export_legacy_ai(&doc, &LegacyAiOptions::default()).is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user