mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
EPS/.ai editing data: type keeps stacked art styles as live appearance
**What:** Illustrator type styled through the Appearance panel came in as its drawn look (outlined compound paths). Comic and "text effect" templates are the common case, and the type couldn't be retyped. This keeps it as editable type with the style as a live VectorCraft appearance. **Changes** - **Art styles parsed as a tree.** Compound filters close at their `;`, basic ones at `/Part`. Each fill and stroke in a stack keeps its own effects, opacity and blend mode, and object-level effects stay on the object. - **Type keeps any plain stack.** That covers several fills and strokes, pattern and gradient fills, Warp, Offset Path, Transform with copies, Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief. Bas Relief settings are left at their defaults, because Illustrator stores them in a Photoshop plug-in's binary block. Gradients and patterns are placed as the drawn look places them. Other objects keep the existing plain-style rule, since their drawn look is the more faithful option. - **Point type without a frame matrix.** Illustrator 24 omits the matrix when point type is neither rotated nor scaled. Such stories were left out entirely; they're now read with an identity matrix. - **Page comparison.** The page's rendering of a style isn't held to match VectorCraft's live rendering where the replaced drawn look lay, and styled made type isn't pruned as unseen. - **EPS override.** `editingData: "always"` / `convert --editing-data` now applies to Illustrator EPS as well as `.ai`. **Tests:** 6 new tests, written from scratch; `vectorcraft-eps` passes all 298 on current main. clippy `-D warnings` and rustfmt are clean for eps, engine and cli. `docs/file-format-parity.md` has been updated, including its revision history. **Clean-room note:** I found the bugs with a user's licensed stock EPS, used locally only; nothing from it is committed. Filter and key meanings come from the public Illustrator Effect docs and public LiveEffect scripting examples (`jntp`, the Warp style order). **Not included:** VectorCraft's pixel effects treat transparent content as empty instead of using Document Raster Effects Settings › Background (white by default). Because of that, Bas Relief on a transparent pattern gives flat grey. That needs a separate renderer change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
committed by
bflatastic
parent
52b63e2198
commit
84d5d8797f
@@ -96,8 +96,9 @@ USAGE:
|
||||
SVG's data-* attributes; --outline-text writes SVG text as paths, --decimals sets SVG's coordinate
|
||||
precision (1–7, default 3). OUT may not be IN when reading
|
||||
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
|
||||
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
|
||||
draws differently from its PDF part (which can be older), as long as it can be read. The
|
||||
--replace-lossy is given. --editing-data opens a .ai or Illustrator EPS from its editing copy
|
||||
even where that draws differently from its PDF part or page (which can be older), as long as
|
||||
it can be read. The
|
||||
result has the export's warnings, and importWarnings for what reading IN left out.
|
||||
--ai-version 7 writes a .ai as the legacy version 7 (layers, groups and type kept; one file
|
||||
per artboard, OUT-<artboard>.ai when there are several; default pdf, PDF-compatible), with
|
||||
|
||||
@@ -244,7 +244,8 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
|
||||
restore_or_import(editing, || {
|
||||
let r = vectorcraft_eps::import_with(bytes, opts.editing_data).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
|
||||
let r = vectorcraft_eps::import_trusting(bytes, opts.editing_data, opts.trust_editing_data)
|
||||
.map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
|
||||
Ok((r.document, r.warnings))
|
||||
})?
|
||||
}
|
||||
|
||||
@@ -116,6 +116,9 @@ pub struct Structure {
|
||||
pub hidden_unread: BTreeSet<String>,
|
||||
/// The names of text objects, by their slot's id.
|
||||
pub slot_names: HashMap<NodeId, String>,
|
||||
/// Where the drawn looks lay that objects read with their art style took the place of (the
|
||||
/// document's coordinates): the page draws Illustrator's rendering of those styles there.
|
||||
pub restyled: Vec<Rect>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -359,7 +362,12 @@ const BLENDS: [BlendMode; 16] = [
|
||||
|
||||
/// The name effects take for blend mode `n` of `Xy` (and of effects' `blnd`).
|
||||
pub(super) fn blend_name(n: usize) -> String {
|
||||
format!("{:?}", BLENDS.get(n).copied().unwrap_or(BlendMode::Normal))
|
||||
format!("{:?}", blend_mode(n))
|
||||
}
|
||||
|
||||
/// Blend mode `n` of `Xy` (Normal when it is out of range).
|
||||
pub(super) fn blend_mode(n: usize) -> BlendMode {
|
||||
BLENDS.get(n).copied().unwrap_or(BlendMode::Normal)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -507,6 +515,8 @@ struct Reader<'a> {
|
||||
mesh: Option<(super::mesh::MeshReader, bool)>,
|
||||
/// The art styles (`%AI9_BeginArtStyles`), by name.
|
||||
styles: HashMap<String, super::style::ArtStyle>,
|
||||
/// See [`Structure::restyled`].
|
||||
restyled: Vec<Rect>,
|
||||
/// 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.
|
||||
@@ -655,6 +665,7 @@ impl<'a> Reader<'a> {
|
||||
group_ended: false,
|
||||
layer_state: None,
|
||||
styles: HashMap::new(),
|
||||
restyled: vec![],
|
||||
mesh: None,
|
||||
cmyk_cache: HashMap::new(),
|
||||
done: false,
|
||||
@@ -1698,14 +1709,10 @@ impl<'a> Reader<'a> {
|
||||
return;
|
||||
};
|
||||
let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None });
|
||||
// The tile's art (y down from the tile's top left) → the pattern's space → art space. The
|
||||
// tile's lower left is the pattern's origin wherever the definition drew it, as the file's
|
||||
// PDF part tiles it.
|
||||
let xf = self.space() * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate((0.0, -tile.height()));
|
||||
if xf.determinant().abs() < 1e-12 || !xf.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
let Some(xf) = pattern_xf(self.space(), matrix, tile) else {
|
||||
self.unreadable("pattern fills");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf };
|
||||
if w == "p" {
|
||||
self.gs.fill = p;
|
||||
@@ -1797,6 +1804,7 @@ impl<'a> Reader<'a> {
|
||||
if code != 1.0 || style.is_empty() || !after {
|
||||
return;
|
||||
}
|
||||
let space = self.space();
|
||||
let Some(f) = self.frames.last_mut() else { return };
|
||||
let n = f.children.len();
|
||||
let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else {
|
||||
@@ -1811,9 +1819,16 @@ impl<'a> Reader<'a> {
|
||||
return;
|
||||
}
|
||||
// A style read here: the object, painted as it says.
|
||||
if let Some(appearance) = self.styles.get(&style).and_then(|st| styled(object, st)) {
|
||||
if let Some(appearance) = self
|
||||
.styles
|
||||
.get(&style)
|
||||
.and_then(|st| styled(object, look, st, &StylePaints { gradients: &self.gradients, patterns: &self.pattern_tiles, space }))
|
||||
{
|
||||
let Some(mut object) = f.children.pop() else { return };
|
||||
let Some(look) = f.children.pop() else { return };
|
||||
if let Some(b) = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible) {
|
||||
self.restyled.push(b);
|
||||
}
|
||||
object.appearance = appearance.into();
|
||||
// The object's transparency is written on its look, after the look's group ends.
|
||||
(object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout);
|
||||
@@ -2356,6 +2371,7 @@ impl<'a> Reader<'a> {
|
||||
template: self.template,
|
||||
hidden_unread: self.hidden_unread,
|
||||
slot_names: self.slot_names,
|
||||
restyled: self.restyled,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
@@ -2454,26 +2470,116 @@ fn path_rule(n: &Node) -> FillRule {
|
||||
}
|
||||
}
|
||||
|
||||
/// `object`'s appearance with style `st`: `None` when the style isn't one this reads, or asks a
|
||||
/// group for fills or strokes.
|
||||
fn styled(object: &Node, st: &super::style::ArtStyle) -> Option<Appearance> {
|
||||
/// The tile's art (y down from the tile's top left) → the pattern's space (`matrix`) → art space
|
||||
/// → the document (`space`). The tile's lower left is the pattern's origin wherever the
|
||||
/// definition drew it, as the file's PDF part tiles it. `None` when it can't be inverted.
|
||||
fn pattern_xf(space: Affine, matrix: Option<Affine>, tile: Rect) -> Option<Affine> {
|
||||
let xf = space * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate((0.0, -tile.height()));
|
||||
(xf.determinant().abs() >= 1e-12 && xf.as_coeffs().iter().all(|v| v.is_finite())).then_some(xf)
|
||||
}
|
||||
|
||||
/// What a style's fills paint with: the file's gradients and pattern tiles, and art space → the document.
|
||||
struct StylePaints<'a> {
|
||||
gradients: &'a HashMap<String, Gradient>,
|
||||
patterns: &'a HashMap<String, Rect>,
|
||||
space: Affine,
|
||||
}
|
||||
|
||||
/// The gradients and patterns `look` paints with, placed as it draws them, by their swatch's name
|
||||
/// (the first of each; a pattern's name after `PATTERN_KEY`).
|
||||
fn placed_paints(look: &Node, out: &mut HashMap<String, Paint>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for item in &look.appearance.items {
|
||||
let key = match item.paint() {
|
||||
Paint::Gradient(g) => g.swatch.clone(),
|
||||
Paint::Pattern { pattern, .. } => Some(format!("{PATTERN_KEY}{pattern}")),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(key) = key {
|
||||
out.entry(key).or_insert_with(|| item.paint().clone());
|
||||
}
|
||||
}
|
||||
for c in look.children().into_iter().flatten() {
|
||||
placed_paints(c, out, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names.
|
||||
const PATTERN_KEY: &str = "\u{1}pattern ";
|
||||
|
||||
/// `object`'s appearance with style `st`, its drawn look `look`: `None` when the style isn't one
|
||||
/// this reads, or asks a group for fills or strokes.
|
||||
fn styled(object: &Node, look: &Node, st: &super::style::ArtStyle, paints: &StylePaints) -> Option<Appearance> {
|
||||
use super::style::StyleItem;
|
||||
if !st.plain || (object.is_container() && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) {
|
||||
// A text object's slot (an empty group standing for it) takes the style as the type will.
|
||||
let slot = slot_of(object.name.as_deref()).is_some();
|
||||
if !st.plain
|
||||
|| (!slot && !st.simple)
|
||||
|| (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// A style with one gradient fill: the object's own gradient, placed as the object's fill is.
|
||||
let gradients = st.items.iter().filter(|i| matches!(i, StyleItem::Gradient { .. })).count();
|
||||
let own_gradient = object.appearance.fill().filter(|f| matches!(f.paint, Paint::Gradient(_)));
|
||||
// A style's gradients are written in a space of the object's bounds that isn't documented; its
|
||||
// drawn look has each where the app placed it.
|
||||
let mut placed = HashMap::new();
|
||||
placed_paints(look, &mut placed, 0);
|
||||
let mut items = vec![];
|
||||
for item in &st.items {
|
||||
items.push(match item {
|
||||
for (n, item) in st.items.iter().enumerate() {
|
||||
let mut layer = match item {
|
||||
StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())),
|
||||
StyleItem::ObjectFill => AppearanceItem::Fill(object.appearance.fill().filter(|f| matches!(f.paint, Paint::Gradient(_)))?.clone()),
|
||||
StyleItem::Gradient { name, angle } => {
|
||||
let paint = match (name.as_ref().and_then(|n| placed.get(n)), gradients, own_gradient) {
|
||||
// Other objects than type: the object's own, as they always were.
|
||||
(_, 1, Some(own)) if !slot => own.paint.clone(),
|
||||
(Some(p), ..) => p.clone(),
|
||||
(None, 1, Some(own)) => own.paint.clone(),
|
||||
_ => {
|
||||
let gradient = name.as_ref().and_then(|n| paints.gradients.get(n))?;
|
||||
let mut g = vectorcraft_color::gradient::GradientPaint::new(gradient.clone());
|
||||
// Fitted to the object's bounds at its angle (art space turns counter-clockwise, as the document's angles do).
|
||||
g.angle = *angle;
|
||||
Paint::Gradient(Box::new(g))
|
||||
}
|
||||
};
|
||||
AppearanceItem::Fill(FillLayer::new(paint))
|
||||
}
|
||||
StyleItem::Pattern { name, matrix } => match placed.get(&format!("{PATTERN_KEY}{name}")) {
|
||||
Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())),
|
||||
None => {
|
||||
let tile = paints.patterns.get(name).copied()?;
|
||||
let xf = pattern_xf(paints.space, matrix.and_then(|m| paint::affine(&m)), tile)?;
|
||||
AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: name.clone(), xf }))
|
||||
}
|
||||
},
|
||||
StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } => {
|
||||
let mut layer = StrokeLayer::new(paint.clone(), *width);
|
||||
(layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone());
|
||||
AppearanceItem::Stroke(layer)
|
||||
}
|
||||
});
|
||||
};
|
||||
if let Some(look) = st.looks.get(n) {
|
||||
match &mut layer {
|
||||
AppearanceItem::Fill(f) => {
|
||||
(f.visible, f.opacity, f.blend, f.effects) = (look.visible, look.opacity, look.blend, look.effects.clone())
|
||||
}
|
||||
AppearanceItem::Stroke(s) => {
|
||||
(s.visible, s.opacity, s.blend, s.effects) = (look.visible, look.opacity, look.blend, look.effects.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
items.push(layer);
|
||||
}
|
||||
Some(Appearance { items, effects: st.effects.clone(), ..(*object.appearance).clone() })
|
||||
let contents_index = if object.is_container() || matches!(object.kind, NodeKind::Text(_) | NodeKind::Image(_)) {
|
||||
st.contents_index
|
||||
} else {
|
||||
object.appearance.contents_index
|
||||
};
|
||||
Some(Appearance { items, effects: st.effects.clone(), contents_index })
|
||||
}
|
||||
|
||||
/// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look).
|
||||
|
||||
+517
-108
@@ -1,63 +1,121 @@
|
||||
//! Art styles (`%AI9_BeginArtStyles`; not publicly documented): the appearance an object's
|
||||
//! `1 (style) XW` names, a chain of filters — a stack of fills and strokes round the object's
|
||||
//! contents (`Conduit Filter`), then live effects, then its transparency (`Blend Style Filter`):
|
||||
//! `1 (style) XW` names, a tree of filters. A compound filter (`/CompoundFilter :`) holds the
|
||||
//! filters after it until the `;` that closes it; a basic one (`/BasicFilter :`) ends at its
|
||||
//! `/Part ,`:
|
||||
//!
|
||||
//! ```text
|
||||
//! /KnownStyle :
|
||||
//! (name) /Name ,
|
||||
//! /ActiveStyle :
|
||||
//! /CompoundFilter :
|
||||
//! (Chain Style Filter) 0 0 /Filter ,
|
||||
//! … (Stack Style Filter) … (Stroke Style Filter) … (Conduit Filter) … (Fill Style Filter) …
|
||||
//! (Adobe Outer Glow) 1 0 /Filter ,
|
||||
//! (Chain Style Filter) 0 0 /Filter , the object: its effects, its stack, its transparency
|
||||
//! 1 /Visible ,
|
||||
//! 2 /Int (blnd) ,
|
||||
//! 0.75 /Real (opac) ,
|
||||
//! 5 /Real (blur) ,
|
||||
//! /FillStyle : 0 O
|
||||
//! c m y k r g b Xa
|
||||
//! /Def ;
|
||||
//! (sclr) ,
|
||||
//! /BasicFilter :
|
||||
//! (Adobe Deform) 1 0 /Filter , an effect on the whole object
|
||||
//! … /Part ,
|
||||
//! /CompoundFilter :
|
||||
//! (Stack Style Filter) 0 0 /Filter , its fills and strokes, the top one first
|
||||
//! … (Stroke Style Filter) … /Part , a stroke with no effects of its own
|
||||
//! /CompoundFilter :
|
||||
//! (Chain Style Filter) 0 0 /Filter , a fill with effects and a transparency of its own
|
||||
//! … (Adobe Offset Path) … (Fill Style Filter) … (Adobe Transform) … (Blend Style Filter) …
|
||||
//! ;
|
||||
//! /Part ,
|
||||
//! … (Conduit Filter) … the object's contents (type's characters, a group's members)
|
||||
//! ;
|
||||
//! /Part ,
|
||||
//! (Blend Style Filter) … the object's own transparency (written on its look too)
|
||||
//! ;
|
||||
//! ```
|
||||
//!
|
||||
//! A style is *plain* when it is one fill and one stroke at most, in solid colours (a fill's gradient
|
||||
//! is the object's own), and only effects read here (Outer Glow, Drop Shadow, Feather) after them. Such
|
||||
//! objects are read as themselves with that fill, stroke and those effects; others keep their
|
||||
//! drawn look. A fill's or stroke's style says whether it paints with `stroke fill Xd`.
|
||||
//! A fill's style paints with a colour (`c m y k r g b Xa`), a pattern (`(name) … [matrix] p`) or a
|
||||
//! gradient (`Bb` … `flag (name) x y angle length … Bg` … `BB`); `stroke fill Xd` says whether it
|
||||
//! paints at all. A blend filter's `mode opacity isolate knockout shape Xy` is its transparency.
|
||||
//!
|
||||
//! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills
|
||||
//! with a pattern or gradient too), each with its own transparency and effects, and the effects of
|
||||
//! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and
|
||||
//! effects; others keep their drawn look.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Dash, Effect, LineCap, LineJoin, WidthProfile};
|
||||
|
||||
use super::lex::{Lexer, Tok};
|
||||
use super::obj::V;
|
||||
use super::paint;
|
||||
|
||||
/// The filters a plain style may chain.
|
||||
const PLAIN: [&str; 8] =
|
||||
["Chain Style Filter", "Stack Style Filter", "Stroke Style Filter", "Fill Style Filter", "Conduit Filter", "Blend Style Filter", GLOW, FEATHER];
|
||||
const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files
|
||||
const FEATHER: &str = "Adobe Feather"; // brand-ok: the filter's name in the files
|
||||
const SHADOW: &str = "Adobe Drop Shadow"; // brand-ok: the filter's name in the files
|
||||
const DEFORM: &str = "Adobe Deform"; // brand-ok: the filter's name in the files
|
||||
const OFFSET: &str = "Adobe Offset Path"; // brand-ok: the filter's name in the files
|
||||
const TRANSFORM: &str = "Adobe Transform"; // brand-ok: the filter's name in the files
|
||||
const GAUSSIAN: &str = "Adobe PSL Gaussian Blur"; // brand-ok: the filter's name in the files
|
||||
const BAS_RELIEF: &str = "PSAdapter_plugin_BsRl";
|
||||
|
||||
const CHAIN: &str = "Chain Style Filter";
|
||||
const STACK: &str = "Stack Style Filter";
|
||||
const FILL: &str = "Fill Style Filter";
|
||||
const STROKE: &str = "Stroke Style Filter";
|
||||
const CONDUIT: &str = "Conduit Filter";
|
||||
const BLEND: &str = "Blend Style Filter";
|
||||
|
||||
/// The warp styles by their number in `DeformStyle` (Effect › Warp's list, from 1).
|
||||
const WARPS: [&str; 15] = [
|
||||
"warp.arc",
|
||||
"warp.arcLower",
|
||||
"warp.arcUpper",
|
||||
"warp.arch",
|
||||
"warp.bulge",
|
||||
"warp.shellLower",
|
||||
"warp.shellUpper",
|
||||
"warp.flag",
|
||||
"warp.wave",
|
||||
"warp.fish",
|
||||
"warp.rise",
|
||||
"warp.fisheye",
|
||||
"warp.inflate",
|
||||
"warp.squeeze",
|
||||
"warp.twist",
|
||||
];
|
||||
|
||||
/// A plain style's paint, as the object comes in.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(super) struct ArtStyle {
|
||||
/// The style reads as [`Self::items`] and [`Self::effects`].
|
||||
pub plain: bool,
|
||||
/// Its fill and stroke, bottom first.
|
||||
/// It is one fill and one stroke at most, in solid colours or the object's own gradient, with
|
||||
/// no transparency or effects of their own, and only Outer Glow, Feather and Drop Shadow on the
|
||||
/// object: what any object is read with. Type takes the other plain styles too (its drawn look
|
||||
/// can't be retyped); other objects keep their drawn look, which is the app's own rendering.
|
||||
pub simple: bool,
|
||||
/// Its fills and strokes, bottom first.
|
||||
pub items: Vec<StyleItem>,
|
||||
/// Its live effects, in the order they apply.
|
||||
/// Each item's own visibility, transparency and effects, in the order of [`Self::items`].
|
||||
pub looks: Vec<ItemLook>,
|
||||
/// The object's live effects, in the order they apply.
|
||||
pub effects: Vec<Effect>,
|
||||
/// How many of [`Self::items`] paint below the object's contents (`None`: none do).
|
||||
pub contents_index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum StyleItem {
|
||||
Fill(Paint),
|
||||
/// A fill with a gradient: the object's own.
|
||||
ObjectFill,
|
||||
/// A fill with a gradient: the swatch `name` at `angle` degrees, fitted to the object (the
|
||||
/// object's own gradient when it is the style's only one).
|
||||
Gradient {
|
||||
name: Option<String>,
|
||||
angle: f64,
|
||||
},
|
||||
/// A fill with pattern swatch `name`, its space taken to art space by `matrix`.
|
||||
Pattern {
|
||||
name: String,
|
||||
matrix: Option<[f64; 6]>,
|
||||
},
|
||||
Stroke {
|
||||
paint: Paint,
|
||||
width: f64,
|
||||
@@ -69,10 +127,28 @@ pub(super) enum StyleItem {
|
||||
},
|
||||
}
|
||||
|
||||
/// A fill's or stroke's own state in the stack.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct ItemLook {
|
||||
pub visible: bool,
|
||||
pub opacity: f32,
|
||||
pub blend: BlendMode,
|
||||
pub effects: Vec<Effect>,
|
||||
}
|
||||
|
||||
impl Default for ItemLook {
|
||||
fn default() -> Self {
|
||||
Self { visible: true, opacity: 1.0, blend: BlendMode::Normal, effects: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Filter {
|
||||
name: String,
|
||||
visible: bool,
|
||||
/// The compound filter it is in (`None`: the style's first).
|
||||
parent: Option<usize>,
|
||||
compound: bool,
|
||||
params: HashMap<String, f64>,
|
||||
/// Its dictionary's arrays (`/Array : v /Real , … ; (key) ,`), by key.
|
||||
arrays: HashMap<String, Vec<f64>>,
|
||||
@@ -86,6 +162,12 @@ struct Filter {
|
||||
/// `stroke fill Xd`.
|
||||
paints: (bool, bool),
|
||||
gradient: bool,
|
||||
/// The gradient's swatch and angle (`Bg`).
|
||||
gradient_of: Option<(Option<String>, f64)>,
|
||||
/// `(name) … [matrix] p`.
|
||||
pattern: Option<(String, Option<[f64; 6]>)>,
|
||||
/// `mode opacity … Xy`.
|
||||
xy: Option<(usize, f32)>,
|
||||
}
|
||||
|
||||
impl Default for Filter {
|
||||
@@ -93,6 +175,8 @@ impl Default for Filter {
|
||||
Self {
|
||||
name: String::new(),
|
||||
visible: true,
|
||||
parent: None,
|
||||
compound: false,
|
||||
params: HashMap::new(),
|
||||
arrays: HashMap::new(),
|
||||
fill: None,
|
||||
@@ -104,16 +188,27 @@ impl Default for Filter {
|
||||
dash: None,
|
||||
paints: (false, false),
|
||||
gradient: false,
|
||||
gradient_of: None,
|
||||
pattern: None,
|
||||
xy: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Most filters read in one style.
|
||||
const MAX_FILTERS: usize = 4096;
|
||||
|
||||
/// The styles of the section `data` (between its markers), by name.
|
||||
pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
|
||||
let mut styles = HashMap::new();
|
||||
let mut name: Option<String> = None;
|
||||
let mut filters: Vec<Filter> = vec![];
|
||||
let mut operands: Vec<V> = vec![];
|
||||
// The compound filters not yet closed, the kind the next `/Filter` is, and whether a basic
|
||||
// filter's dictionaries are being read (their `;` close those, not a compound filter).
|
||||
let mut open: Vec<usize> = vec![];
|
||||
let mut pending: Option<bool> = None;
|
||||
let mut in_basic = false;
|
||||
let mut finish = |name: Option<String>, filters: &mut Vec<Filter>| {
|
||||
if let Some(name) = name {
|
||||
styles.insert(name, style(filters));
|
||||
@@ -135,9 +230,28 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
|
||||
b"KnownStyle" => {
|
||||
finish(name.take(), &mut filters);
|
||||
operands.clear();
|
||||
open.clear();
|
||||
(pending, in_basic) = (None, false);
|
||||
}
|
||||
b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(V::text),
|
||||
b"Filter" => filters.push(Filter { name: operands.iter().rev().find_map(V::text).unwrap_or_default(), ..Filter::default() }),
|
||||
b"CompoundFilter" => pending = Some(true),
|
||||
b"BasicFilter" => pending = Some(false),
|
||||
b"Part" => in_basic = false,
|
||||
b"Filter" => {
|
||||
let compound = pending.take().unwrap_or(false);
|
||||
if filters.len() < MAX_FILTERS {
|
||||
filters.push(Filter {
|
||||
name: operands.iter().rev().find_map(V::text).unwrap_or_default(),
|
||||
parent: open.last().copied(),
|
||||
compound,
|
||||
..Filter::default()
|
||||
});
|
||||
if compound {
|
||||
open.push(filters.len() - 1);
|
||||
}
|
||||
}
|
||||
in_basic = !compound;
|
||||
}
|
||||
b"Visible" => {
|
||||
if let Some(f) = filters.last_mut() {
|
||||
f.visible = operands.iter().rev().find_map(V::num) != Some(0.0);
|
||||
@@ -181,6 +295,9 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
|
||||
operands.clear();
|
||||
continue;
|
||||
}
|
||||
if op == ";" && !in_basic {
|
||||
open.pop();
|
||||
}
|
||||
if let Some(f) = filters.last_mut() {
|
||||
paint_op(f, op, &operands);
|
||||
}
|
||||
@@ -245,7 +362,37 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V]) {
|
||||
f.paints = (*stroke != 0.0, *fill != 0.0);
|
||||
}
|
||||
}
|
||||
"Bg" => f.gradient = true,
|
||||
"Xy" => {
|
||||
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
|
||||
if let [mode, opacity, ..] = n.as_slice()
|
||||
&& mode.is_finite()
|
||||
&& opacity.is_finite()
|
||||
{
|
||||
f.xy = Some((mode.max(0.0) as usize, opacity.clamp(0.0, 1.0) as f32));
|
||||
}
|
||||
}
|
||||
// `flag (name) x y angle length a b c d tx ty Bg`.
|
||||
"Bg" => {
|
||||
f.gradient = true;
|
||||
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)));
|
||||
let name = at.and_then(|i| vals.get(i)).and_then(V::text);
|
||||
let n: Vec<f64> = at.map_or(&[][..], |i| vals.get(i + 1..).unwrap_or_default()).iter().filter_map(V::num).collect();
|
||||
let angle = n.get(2).copied().filter(|a| a.is_finite()).unwrap_or(0.0);
|
||||
f.gradient_of = Some((name, angle));
|
||||
}
|
||||
"p" => {
|
||||
let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None });
|
||||
let matrix = vals.iter().rev().find_map(|v| match v {
|
||||
V::Arr(a) => {
|
||||
let n: Vec<f64> = a.iter().filter_map(V::num).collect();
|
||||
<[f64; 6]>::try_from(n.as_slice()).ok().filter(|m| m.iter().all(|v| v.is_finite()))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some(name) = name {
|
||||
f.pattern = Some((name, matrix));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -275,91 +422,237 @@ fn width_profile(arrays: &HashMap<String, Vec<f64>>) -> Option<WidthProfile> {
|
||||
(!uniform).then_some(WidthProfile { points })
|
||||
}
|
||||
|
||||
fn style(filters: &[Filter]) -> ArtStyle {
|
||||
let count = |name: &str| filters.iter().filter(|f| f.name == name).count();
|
||||
let painted = filters.iter().rposition(|f| matches!(f.name.as_str(), "Stroke Style Filter" | "Fill Style Filter" | "Conduit Filter"));
|
||||
let mut plain = count("Stroke Style Filter") <= 1 && count("Fill Style Filter") <= 1;
|
||||
let mut effects = vec![];
|
||||
let mut items = vec![];
|
||||
// The stack lists its fills and strokes top first.
|
||||
for f in filters.iter().rev().filter(|f| f.visible) {
|
||||
match f.name.as_str() {
|
||||
"Fill Style Filter" if f.paints.1 && f.gradient => items.push(StyleItem::ObjectFill),
|
||||
"Fill Style Filter" if f.paints.1 => match &f.fill {
|
||||
Some(p) => items.push(StyleItem::Fill(p.clone())),
|
||||
None => plain = false,
|
||||
},
|
||||
// Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end
|
||||
// and the pattern, fitted, between them.
|
||||
"Stroke Style Filter" if f.paints.0 && f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) => plain = false,
|
||||
"Stroke Style Filter" if f.paints.0 => match &f.stroke {
|
||||
Some(p) if !f.gradient => items.push(StyleItem::Stroke {
|
||||
paint: p.clone(),
|
||||
width: f.width,
|
||||
cap: f.cap,
|
||||
join: f.join,
|
||||
miter: f.miter,
|
||||
dash: f.dash.clone(),
|
||||
profile: width_profile(&f.arrays),
|
||||
}),
|
||||
_ => plain = false,
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (i, f) in filters.iter().enumerate() {
|
||||
let effect = f.name == GLOW || f.name == FEATHER || f.name == SHADOW;
|
||||
if !PLAIN.contains(&f.name.as_str()) && f.name != SHADOW {
|
||||
plain = false;
|
||||
}
|
||||
// An effect inside the stack is a fill's or a stroke's, not the object's.
|
||||
if effect && painted.is_some_and(|p| i < p) {
|
||||
plain = false;
|
||||
}
|
||||
if !f.visible {
|
||||
// A hidden fill or stroke leaves the object painting differently; a hidden effect, nothing.
|
||||
plain &= effect;
|
||||
continue;
|
||||
}
|
||||
let param = |k: &str| f.params.get(k).copied();
|
||||
let mode = |default: usize| super::read::blend_name(param("blnd").map_or(default, |v| v.max(0.0) as usize));
|
||||
let color = f.fill.as_ref().and_then(Paint::color).unwrap_or(vectorcraft_color::Color::BLACK);
|
||||
match f.name.as_str() {
|
||||
// `blur`'s scale isn't publicly documented: read as the Gaussian's σ, an effect's blur being 2σ.
|
||||
GLOW => effects.push(Effect {
|
||||
id: "stylize.outerGlow".into(),
|
||||
params: json!({"mode": mode(2), "opacity": param("opac").unwrap_or(0.75) * 100.0, "blur": 2.0 * param("blur").unwrap_or(5.0), "color": color}),
|
||||
visible: true,
|
||||
}),
|
||||
// LiveEffect XML uses `radius`; art styles abbreviate it `rad` (public scripting posts on applyEffect).
|
||||
FEATHER => effects.push(Effect {
|
||||
id: "stylize.feather".into(),
|
||||
params: json!({"radius": param("rad").or_else(|| param("radius")).filter(|v| v.is_finite()).unwrap_or(5.0)}),
|
||||
visible: true,
|
||||
}),
|
||||
SHADOW => {
|
||||
let mut params = json!({
|
||||
"mode": mode(1),
|
||||
"opacity": param("opac").unwrap_or(0.75) * 100.0,
|
||||
"blur": 2.0 * param("blur").unwrap_or(5.0),
|
||||
// Undocumented; read as the dialog's offsets, not flipped like art-space y: its default 7/7 pt is
|
||||
// right and down (https://helpx.adobe.com/illustrator/using/drop-shadows-glows-feathering.html).
|
||||
"x": param("horz").unwrap_or(7.0),
|
||||
"y": param("vert").unwrap_or(7.0),
|
||||
"color": color,
|
||||
});
|
||||
// A `csrc` of 1 is Darkness, the object's own colors with black added; any value other than 0 is read as
|
||||
// Darkness. `dark` is the percent of black (0 to 100, where `opac` is a fraction), and 100 when it is missing or not finite.
|
||||
if param("csrc").unwrap_or(0.0) != 0.0 {
|
||||
params["shade"] = json!("darkness");
|
||||
params["darkness"] = json!(param("dark").filter(|v| v.is_finite()).map_or(100.0, |v| v.clamp(0.0, 100.0)));
|
||||
}
|
||||
effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true });
|
||||
/// The live effect filter `f` is, when it is one read here.
|
||||
fn effect(f: &Filter) -> Option<Effect> {
|
||||
let param = |k: &str| f.params.get(k).copied().filter(|v| v.is_finite());
|
||||
let flag = |k: &str| param(k).is_some_and(|v| v != 0.0);
|
||||
let mode = |default: usize| super::read::blend_name(param("blnd").map_or(default, |v| v.max(0.0) as usize));
|
||||
let color = f.fill.as_ref().and_then(Paint::color).unwrap_or(vectorcraft_color::Color::BLACK);
|
||||
let (id, params) = match f.name.as_str() {
|
||||
// `blur`'s scale isn't publicly documented: read as the Gaussian's σ, an effect's blur being 2σ.
|
||||
GLOW => (
|
||||
"stylize.outerGlow",
|
||||
json!({"mode": mode(2), "opacity": param("opac").unwrap_or(0.75) * 100.0, "blur": 2.0 * param("blur").unwrap_or(5.0), "color": color}),
|
||||
),
|
||||
// LiveEffect XML uses `radius`; art styles abbreviate it `rad` (public scripting posts on applyEffect).
|
||||
FEATHER => ("stylize.feather", json!({"radius": param("rad").or_else(|| param("radius")).unwrap_or(5.0)})),
|
||||
SHADOW => {
|
||||
let mut params = json!({
|
||||
"mode": mode(1),
|
||||
"opacity": param("opac").unwrap_or(0.75) * 100.0,
|
||||
"blur": 2.0 * param("blur").unwrap_or(5.0),
|
||||
// Undocumented; read as the dialog's offsets, not flipped like art-space y: its default 7/7 pt is
|
||||
// right and down (https://helpx.adobe.com/illustrator/using/drop-shadows-glows-feathering.html).
|
||||
"x": param("horz").unwrap_or(7.0),
|
||||
"y": param("vert").unwrap_or(7.0),
|
||||
"color": color,
|
||||
});
|
||||
// A `csrc` of 1 is Darkness, the object's own colors with black added; any value other than 0 is read as
|
||||
// Darkness. `dark` is the percent of black (0 to 100, where `opac` is a fraction), and 100 when it is missing or not finite.
|
||||
if param("csrc").unwrap_or(0.0) != 0.0 {
|
||||
params["shade"] = json!("darkness");
|
||||
params["darkness"] = json!(param("dark").map_or(100.0, |v| v.clamp(0.0, 100.0)));
|
||||
}
|
||||
_ => {}
|
||||
("stylize.dropShadow", params)
|
||||
}
|
||||
// Effect › Warp: `DeformStyle` numbers the styles from 1 as the dialog lists them, `Rotate`
|
||||
// is Vertical, and the bend and distortions are fractions.
|
||||
DEFORM => {
|
||||
let style = param("DeformStyle").map_or(1, |v| v as i64);
|
||||
let id = WARPS.get(usize::try_from(style - 1).ok()?)?;
|
||||
let pct = |k: &str| (param(k).unwrap_or(0.0) * 100.0).clamp(-100.0, 100.0);
|
||||
(
|
||||
*id,
|
||||
json!({
|
||||
"bend": pct("DeformValue"),
|
||||
"horizontal": pct("DeformHoriz"),
|
||||
"vertical": pct("DeformVert"),
|
||||
"orientation": if flag("Rotate") { "vertical" } else { "horizontal" },
|
||||
}),
|
||||
)
|
||||
}
|
||||
// `jntp`: 0 round, 1 bevel, 2 miter (the joins LiveEffect XML writes in public scripts).
|
||||
OFFSET => (
|
||||
"path.offsetPath",
|
||||
json!({
|
||||
"offset": param("ofst").unwrap_or(10.0).clamp(-1e5, 1e5),
|
||||
"joins": match param("jntp").map(|v| v as i64) { Some(0) => "round", Some(1) => "bevel", _ => "miter" },
|
||||
"miterLimit": param("mlim").unwrap_or(4.0).clamp(1.0, 500.0),
|
||||
}),
|
||||
),
|
||||
// Art space has y up: a move up is a negative `moveV` in the document.
|
||||
TRANSFORM => (
|
||||
"distort.transform",
|
||||
json!({
|
||||
"scaleH": param("scaleH_Percent").unwrap_or(100.0),
|
||||
"scaleV": param("scaleV_Percent").unwrap_or(100.0),
|
||||
"moveH": param("moveH_Pts").unwrap_or(0.0),
|
||||
"moveV": -param("moveV_Pts").unwrap_or(0.0),
|
||||
"rotate": param("rotate_Degrees").unwrap_or(0.0),
|
||||
"copies": param("numCopies").unwrap_or(0.0).clamp(0.0, 1000.0) as i64,
|
||||
"reflectX": flag("reflectX"),
|
||||
"reflectY": flag("reflectY"),
|
||||
"reference": param("pinPoint").unwrap_or(4.0).clamp(0.0, 8.0) as i64,
|
||||
"random": flag("randomize"),
|
||||
}),
|
||||
),
|
||||
GAUSSIAN => ("blur.gaussian", json!({"radius": param("blur").unwrap_or(5.0).clamp(0.0, 1000.0)})),
|
||||
// Its settings are in a Photoshop plug-in's binary block: the effect's defaults stand in.
|
||||
BAS_RELIEF => ("sketch.basRelief", json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"})),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Effect { id: id.into(), params, visible: f.visible })
|
||||
}
|
||||
|
||||
/// The fill or stroke basic filter `f` paints, `Ok(None)` when it paints nothing, `Err` when it
|
||||
/// paints in a way this doesn't read.
|
||||
fn paint_item(f: &Filter) -> Result<Option<StyleItem>, ()> {
|
||||
match f.name.as_str() {
|
||||
FILL if !f.paints.1 => Ok(None),
|
||||
FILL => match (&f.pattern, &f.gradient_of, &f.fill) {
|
||||
(Some((name, matrix)), ..) => Ok(Some(StyleItem::Pattern { name: name.clone(), matrix: *matrix })),
|
||||
(None, Some((name, angle)), _) => Ok(Some(StyleItem::Gradient { name: name.clone(), angle: *angle })),
|
||||
(None, None, _) if f.gradient => Ok(Some(StyleItem::Gradient { name: None, angle: 0.0 })),
|
||||
(None, None, Some(p)) => Ok(Some(StyleItem::Fill(p.clone()))),
|
||||
(None, None, None) => Err(()),
|
||||
},
|
||||
STROKE if !f.paints.0 => Ok(None),
|
||||
// Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end
|
||||
// and the pattern, fitted, between them.
|
||||
STROKE if f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) => Err(()),
|
||||
STROKE => match &f.stroke {
|
||||
Some(p) if !f.gradient && f.pattern.is_none() => Ok(Some(StyleItem::Stroke {
|
||||
paint: p.clone(),
|
||||
width: f.width,
|
||||
cap: f.cap,
|
||||
join: f.join,
|
||||
miter: f.miter,
|
||||
dash: f.dash.clone(),
|
||||
profile: width_profile(&f.arrays),
|
||||
})),
|
||||
_ => Err(()),
|
||||
},
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an [`ArtStyle`] from a style's filter tree.
|
||||
struct Builder<'a> {
|
||||
filters: &'a [Filter],
|
||||
plain: bool,
|
||||
}
|
||||
|
||||
impl Builder<'_> {
|
||||
fn children(&self, parent: usize) -> Vec<usize> {
|
||||
self.filters.iter().enumerate().filter(|(_, f)| f.parent == Some(parent)).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// The fill or stroke a stack holds as `id`: a basic one, or a chain of one with its effects
|
||||
/// and transparency.
|
||||
fn item(&mut self, id: usize) -> Option<(StyleItem, ItemLook)> {
|
||||
let filters = self.filters;
|
||||
let f = &filters[id];
|
||||
if !f.compound {
|
||||
return match paint_item(f) {
|
||||
Ok(item) => item.map(|i| (i, ItemLook { visible: f.visible, ..ItemLook::default() })),
|
||||
Err(()) => {
|
||||
self.plain = false;
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
if f.name != CHAIN {
|
||||
self.plain = false;
|
||||
return None;
|
||||
}
|
||||
let mut look = ItemLook { visible: f.visible, ..ItemLook::default() };
|
||||
let mut painted: Option<StyleItem> = None;
|
||||
let mut paints = 0;
|
||||
for k in self.children(id) {
|
||||
let c = &filters[k];
|
||||
match c.name.as_str() {
|
||||
_ if c.compound => self.plain = false,
|
||||
FILL | STROKE => {
|
||||
paints += 1;
|
||||
look.visible &= c.visible;
|
||||
match paint_item(c) {
|
||||
Ok(item) => painted = item,
|
||||
Err(()) => self.plain = false,
|
||||
}
|
||||
}
|
||||
BLEND => {
|
||||
if let Some((mode, opacity)) = c.xy {
|
||||
(look.blend, look.opacity) = (super::read::blend_mode(mode), opacity);
|
||||
}
|
||||
}
|
||||
_ => match effect(c) {
|
||||
Some(e) => look.effects.push(e),
|
||||
None => self.plain = false,
|
||||
},
|
||||
}
|
||||
}
|
||||
if paints != 1 {
|
||||
self.plain = false;
|
||||
}
|
||||
painted.map(|p| (p, look))
|
||||
}
|
||||
|
||||
/// A stack's fills and strokes, bottom first, and how many of them paint below its contents.
|
||||
fn stack(&mut self, id: usize, st: &mut ArtStyle) {
|
||||
// Listed top first.
|
||||
let mut top_first = vec![];
|
||||
let mut contents_at = None;
|
||||
for k in self.children(id) {
|
||||
if self.filters[k].name == CONDUIT {
|
||||
contents_at = Some(top_first.len());
|
||||
continue;
|
||||
}
|
||||
if let Some(item) = self.item(k) {
|
||||
top_first.push(item);
|
||||
}
|
||||
}
|
||||
let below = contents_at.map(|at| top_first.len() - at);
|
||||
for (item, look) in top_first.into_iter().rev() {
|
||||
st.items.push(item);
|
||||
st.looks.push(look);
|
||||
}
|
||||
st.contents_index = below.filter(|n| *n > 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn style(filters: &[Filter]) -> ArtStyle {
|
||||
let mut st = ArtStyle { plain: true, ..ArtStyle::default() };
|
||||
let Some(root) = filters.iter().position(|f| f.parent.is_none()) else { return st };
|
||||
let mut b = Builder { filters, plain: true };
|
||||
let top = if filters[root].compound { b.children(root) } else { vec![root] };
|
||||
for id in top {
|
||||
let f = &filters[id];
|
||||
match f.name.as_str() {
|
||||
STACK if f.compound => b.stack(id, &mut st),
|
||||
FILL | STROKE | CHAIN => {
|
||||
if let Some((item, look)) = b.item(id) {
|
||||
st.items.push(item);
|
||||
st.looks.push(look);
|
||||
}
|
||||
}
|
||||
CONDUIT => {}
|
||||
// The object's own transparency, which its look carries too.
|
||||
BLEND => {}
|
||||
_ => match effect(f) {
|
||||
Some(e) => st.effects.push(e),
|
||||
None => b.plain = false,
|
||||
},
|
||||
}
|
||||
}
|
||||
ArtStyle { plain, items, effects }
|
||||
st.plain = b.plain;
|
||||
let count = |stroke: bool| st.items.iter().filter(|i| matches!(i, StyleItem::Stroke { .. }) == stroke).count();
|
||||
st.simple = st.plain
|
||||
&& count(true) <= 1
|
||||
&& count(false) <= 1
|
||||
&& st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { .. } | StyleItem::Gradient { .. }))
|
||||
&& st.looks.iter().all(|l| l.visible && l.opacity >= 1.0 && l.blend == BlendMode::Normal && l.effects.is_empty())
|
||||
&& st.effects.iter().all(|e| matches!(e.id.as_str(), "stylize.outerGlow" | "stylize.feather" | "stylize.dropShadow"));
|
||||
st
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -483,4 +776,120 @@ mod tests {
|
||||
]);
|
||||
assert_eq!(width_profile(&arrays).map(|p| p.points), Some(vec![(0.0, 2.0, 2.0), (0.5, 0.5, 0.5), (1.0, 0.0, 0.0)]));
|
||||
}
|
||||
/// A basic filter `name` whose dictionary holds `body`.
|
||||
fn basic(name: &str, body: &str) -> String {
|
||||
format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n")
|
||||
}
|
||||
|
||||
/// A compound filter `name` holding `kids`.
|
||||
fn compound(name: &str, kids: &[String]) -> String {
|
||||
format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat())
|
||||
}
|
||||
|
||||
fn fill(paint: &str) -> String {
|
||||
basic(
|
||||
"Fill Style Filter",
|
||||
&format!("0 /Bool (UseEvenOdd) ,\n/FillStyle : 0 O\n{paint}\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"),
|
||||
)
|
||||
}
|
||||
|
||||
fn blend(mode: u8, opacity: f64) -> String {
|
||||
basic("Blend Style Filter", &format!("/BlendStyle : {mode} {opacity} 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n/Def ;\n (BlendStyle) ,\n"))
|
||||
}
|
||||
|
||||
/// A comic-lettering style: a warp over a stack of a textured fill, a gradient, a white rim
|
||||
/// repeated by Transform, and the characters at the bottom.
|
||||
fn lettering() -> String {
|
||||
let stack = compound(
|
||||
"Stack Style Filter",
|
||||
&[
|
||||
basic("Stroke Style Filter", "/StrokeStyle : 0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n"),
|
||||
compound(
|
||||
"Chain Style Filter",
|
||||
&[fill("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), basic("PSAdapter_plugin_BsRl", "300 /Int (PrevDres) ,\n"), blend(5, 0.48)],
|
||||
),
|
||||
fill("Bb\n0 0 0 0 Bh\n2 (Red ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB"),
|
||||
compound(
|
||||
"Chain Style Filter",
|
||||
&[
|
||||
basic("Adobe Offset Path", "3.5 /Real (ofst) ,\n0 /Int (jntp) ,\n4 /Real (mlim) ,\n"),
|
||||
fill("0 0 0 0 1 1 1 Xa"),
|
||||
basic(
|
||||
"Adobe Transform",
|
||||
"6 /Int (numCopies) ,\n0.75 /Real (moveH_Pts) ,\n-0.25 /Real (moveV_Pts) ,\n100 /Real (scaleH_Percent) ,\n100 /Real (scaleV_Percent) ,\n4 /Int (pinPoint) ,\n",
|
||||
),
|
||||
],
|
||||
),
|
||||
basic("Conduit Filter", ""),
|
||||
],
|
||||
);
|
||||
let root = compound(
|
||||
"Chain Style Filter",
|
||||
&[
|
||||
basic(
|
||||
"Adobe Deform",
|
||||
"0 /Bool (Rotate) ,\n0.02 /Real (DeformVert) ,\n4 /Int (DeformStyle) ,\n0.05 /Real (DeformValue) ,\n0 /Real (DeformHoriz) ,\n",
|
||||
),
|
||||
stack,
|
||||
blend(0, 1.0),
|
||||
],
|
||||
);
|
||||
format!("/KnownStyle :\n(Lettering) /Name ,\n/ActiveStyle :\n{root}\n/Execution ;\n /Def ;\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() {
|
||||
// GUARDS: more than one fill, or an effect on a fill, made the style not plain, and type
|
||||
// with it came in as its drawn look (outlines) instead of type.
|
||||
let styles = art_styles(lettering().as_bytes());
|
||||
let st = &styles["Lettering"];
|
||||
assert!(st.plain, "{st:?}");
|
||||
// Bottom first: the rim, the gradient, the textured fill; the stroke paints nothing.
|
||||
let [StyleItem::Fill(rim), StyleItem::Gradient { name, angle }, StyleItem::Pattern { name: pattern, .. }] = st.items.as_slice() else {
|
||||
panic!("{:?}", st.items)
|
||||
};
|
||||
assert!(matches!(rim, Paint::Solid { .. }), "{rim:?}");
|
||||
assert_eq!((name.as_deref(), *angle, pattern.as_str()), (Some("Red ramp"), 90.0, "Dots"));
|
||||
let ids = |l: &ItemLook| l.effects.iter().map(|e| e.id.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&st.looks[0]), ["path.offsetPath", "distort.transform"]);
|
||||
assert!(ids(&st.looks[1]).is_empty());
|
||||
assert_eq!(ids(&st.looks[2]), ["sketch.basRelief"]);
|
||||
assert_eq!((st.looks[2].blend, st.looks[2].opacity), (BlendMode::HardLight, 0.48));
|
||||
let transform = &st.looks[0].effects[1].params;
|
||||
assert_eq!((&transform["copies"], &transform["moveH"], &transform["moveV"]), (&json!(6), &json!(0.75), &json!(0.25)));
|
||||
assert_eq!(st.looks[0].effects[0].params["joins"], json!("round"));
|
||||
// The warp is the whole object's; the characters paint below every item.
|
||||
let [warp] = st.effects.as_slice() else { panic!("{:?}", st.effects) };
|
||||
assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["orientation"]), ("warp.arch", &json!(5.0), &json!("horizontal")));
|
||||
assert_eq!(st.contents_index, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contents_above_some_items_count_them() {
|
||||
let stack = compound("Stack Style Filter", &[fill("0 0 0 0 1 1 1 Xa"), basic("Conduit Filter", ""), fill("0 0 0 1 0 0 0 Xa")]);
|
||||
let data = format!("/KnownStyle :\n(Over) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[stack]));
|
||||
let st = &art_styles(data.as_bytes())["Over"];
|
||||
assert!(st.plain, "{st:?}");
|
||||
assert_eq!((st.items.len(), st.contents_index), (2, Some(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_effect_not_read_here_keeps_the_drawn_look() {
|
||||
let item = compound("Chain Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), basic("Adobe Scribble Fill", "")]);
|
||||
let data =
|
||||
format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])]));
|
||||
assert!(!art_styles(data.as_bytes())["Odd"].plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_fill_comes_in_hidden() {
|
||||
let hidden = fill("0 0 0 1 0 0 0 Xa").replacen("1 /Visible", "0 /Visible", 1);
|
||||
let data = format!(
|
||||
"/KnownStyle :\n(Hidden) /Name ,\n/ActiveStyle :\n{}",
|
||||
compound("Chain Style Filter", &[compound("Stack Style Filter", &[fill("0 0 0 0 1 1 1 Xa"), hidden])])
|
||||
);
|
||||
let st = &art_styles(data.as_bytes())["Hidden"];
|
||||
assert!(st.plain, "{st:?}");
|
||||
assert_eq!(st.looks.iter().map(|l| l.visible).collect::<Vec<_>>(), [false, true]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +362,12 @@ impl Texts {
|
||||
(rest.is_empty() && !chunks.is_empty() && chunks.len() <= MAX_SEGMENTS).then(|| chunks.to_vec())
|
||||
};
|
||||
match carries.get("0").and_then(Val::num).unwrap_or(0.0) as i64 {
|
||||
0 => Some(FrameKind::Point(carries.get("2")?.nums()?.try_into().ok()?)),
|
||||
// Point type that is neither turned nor scaled has no matrix (Illustrator 24 leaves it out):
|
||||
// its layout is already where it goes on the canvas.
|
||||
0 => Some(FrameKind::Point(match carries.get("2") {
|
||||
None => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
|
||||
Some(m) => m.nums()?.try_into().ok()?,
|
||||
})),
|
||||
1 => Some(FrameKind::Area(segments()?)),
|
||||
2 => {
|
||||
let range = carries.get("6").and_then(Val::nums).unwrap_or_default();
|
||||
|
||||
@@ -233,6 +233,12 @@ pub fn page(bytes: &[u8]) -> Result<Rect, String> {
|
||||
/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data`
|
||||
/// (else what its page prints, as any EPS).
|
||||
pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> {
|
||||
import_trusting(bytes, editing_data, false)
|
||||
}
|
||||
|
||||
/// [`import_with`]; `trust`: an Illustrator EPS's layers are used even where they draw differently
|
||||
/// from its page, with a note (`editingData: "always"`, as for a `.ai`).
|
||||
pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result<Imported, String> {
|
||||
let ps = program(bytes)?;
|
||||
let dsc = Dsc::read(ps);
|
||||
// A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no
|
||||
@@ -260,7 +266,7 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String>
|
||||
}
|
||||
let drew = !out.drawn.is_empty();
|
||||
let why = match (&result, drew) {
|
||||
(Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out))),
|
||||
(Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust)),
|
||||
(Ok(()), true) => return Ok(finish(out)),
|
||||
(Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(),
|
||||
(Err(e), _) => reason(e, fault.as_ref()),
|
||||
|
||||
@@ -234,6 +234,8 @@ fn fill_slots(
|
||||
let frame = ai::slot_frame(n.name.as_deref());
|
||||
let state = (n.opacity, n.blend, n.isolate, n.knockout);
|
||||
let transparent = see_through(n);
|
||||
// An art style read onto the slot (fills, strokes and effects round the characters) is the type's.
|
||||
let styled = (!n.appearance.items.is_empty() || !n.appearance.effects.is_empty()).then(|| n.appearance.clone());
|
||||
let replacement = match content.next() {
|
||||
Some(Content::Page(parts)) if !parts.is_empty() => {
|
||||
// The page's type (and the strokes round it) take the slot's place as they are.
|
||||
@@ -286,6 +288,11 @@ fn fill_slots(
|
||||
made.push((story, frame, text.id));
|
||||
}
|
||||
text.visible = visible;
|
||||
// Type with an art style is what the page draws as the style's look: it is seen.
|
||||
let seen = seen || styled.is_some();
|
||||
if let Some(appearance) = styled {
|
||||
text.appearance = appearance;
|
||||
}
|
||||
// Type the page shows is kept; other made type is weighed against the page.
|
||||
text.name = if seen { name.cloned() } else { Some(format!("{MADE}{}", name.map_or("", String::as_str))) };
|
||||
let extra: Vec<Arc<Node>> = extra.iter().map(|p| reid(doc, p)).collect();
|
||||
@@ -833,6 +840,10 @@ struct PageView {
|
||||
page: vectorcraft_render::Rendered,
|
||||
/// Where the page's own type is, in its pixels.
|
||||
page_type: Vec<Rect>,
|
||||
/// Where objects read with their art style lie, in its pixels: the page has Illustrator's
|
||||
/// rendering of the style there, the layers VectorCraft's live effects, and the two aren't
|
||||
/// held to look alike.
|
||||
restyled: Vec<Rect>,
|
||||
layers: vectorcraft_render::Renderer,
|
||||
scale: f64,
|
||||
samples: u32,
|
||||
@@ -848,7 +859,18 @@ impl PageView {
|
||||
let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples);
|
||||
let mut page_type = vec![];
|
||||
type_areas(&frame.page.layers, ra, scale, &mut page_type);
|
||||
Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale, samples }
|
||||
Self { page, page_type, restyled: vec![], layers: vectorcraft_render::Renderer::new(), scale, samples }
|
||||
}
|
||||
|
||||
/// Leave the drawn looks in `looks` (on `region`, the page's area in the layers' document) out
|
||||
/// of the comparisons ([`Structure::restyled`](ai::Structure)).
|
||||
fn restyled(&mut self, looks: &[Rect], region: Rect) {
|
||||
let s = self.scale;
|
||||
self.restyled = looks
|
||||
.iter()
|
||||
.map(|r| r.inflate(OUTLINE_REACH, OUTLINE_REACH))
|
||||
.map(|r| Rect::new((r.x0 - region.x0) * s, (r.y0 - region.y0) * s, (r.x1 - region.x0) * s, (r.y1 - region.y0) * s))
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]).
|
||||
@@ -859,7 +881,7 @@ impl PageView {
|
||||
let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples);
|
||||
let mut areas = self.page_type.clone();
|
||||
type_areas(&b.layers, rb, self.scale, &mut areas);
|
||||
compared(&self.page, &y, &areas)
|
||||
compared(&self.page, &y, &areas, &self.restyled)
|
||||
}
|
||||
|
||||
/// The share (0 to 1) of the pixels where `b` on `rb` draws another colour than the page
|
||||
@@ -890,19 +912,22 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference {
|
||||
let mut areas = vec![];
|
||||
type_areas(&a.layers, ra, scale, &mut areas);
|
||||
type_areas(&b.layers, rb, scale, &mut areas);
|
||||
compared(&x, &y, &areas)
|
||||
compared(&x, &y, &areas, &[])
|
||||
}
|
||||
|
||||
/// How two drawings of the same size differ, each drawn on nothing and seen on white, away from
|
||||
/// the type in `areas` (pixels). Pixels are compared by lightness, and by whether they are inked
|
||||
/// the type in `areas` (pixels), pixels in `skip` counting as alike. Pixels are compared by lightness, and by whether they are inked
|
||||
/// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents,
|
||||
/// and what the comparison looks for is art that is missing or out of place.
|
||||
fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect]) -> Difference {
|
||||
fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect], skip: &[Rect]) -> Difference {
|
||||
if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height {
|
||||
return Difference { any: 1.0, art: 1.0, solid: 1.0 };
|
||||
}
|
||||
let differs: Vec<bool> = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).map(|(p, q)| differ(p, q)).collect();
|
||||
let (w, h) = (x.width as usize, x.height as usize);
|
||||
// Pixels in `skip` count as alike.
|
||||
let skipped = in_areas(skip, w, h);
|
||||
let differs: Vec<bool> =
|
||||
x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).zip(&skipped).map(|((p, q), s)| !*s && differ(p, q)).collect();
|
||||
let at = |i: usize, j: usize| j.checked_mul(w).and_then(|row| row.checked_add(i)).and_then(|k| differs.get(k)).copied().unwrap_or(false);
|
||||
let typed = in_areas(areas, w, h);
|
||||
let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count();
|
||||
@@ -1002,7 +1027,8 @@ struct Frame<'a> {
|
||||
fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, trust: bool, unit: f64) -> Result<(Document, Vec<String>), String> {
|
||||
// An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art
|
||||
// space, as its PDF part may (#2031).
|
||||
let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, warnings } = ai::read(data, matches!(page, Page::Art))?;
|
||||
let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, warnings } =
|
||||
ai::read(data, matches!(page, Page::Art))?;
|
||||
// Editing data in the units of the page's user space ([`in_units`]) is compared with the page
|
||||
// scaled down to those units, and the document read from it is scaled up by `unit`.
|
||||
let board = artboard.map(|a| to_doc.transform_rect_bbox(a));
|
||||
@@ -1153,6 +1179,7 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, tr
|
||||
match frame {
|
||||
Some(frame) => {
|
||||
let mut view = PageView::new(&frame);
|
||||
view.restyled(&restyled, frame.rect);
|
||||
let unseen = prune_unseen(&frame, &mut view, &mut doc);
|
||||
if unseen > 0 {
|
||||
notes.push(format!("{unseen} {UNSEEN_TEXT}"));
|
||||
@@ -1327,9 +1354,9 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result
|
||||
/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the
|
||||
/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it
|
||||
/// has one that can't be used, `visible` with a warning that says why.
|
||||
pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
||||
pub(super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported {
|
||||
let Some(data) = editing_copy(ps) else { return visible };
|
||||
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, false, 1.0));
|
||||
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, trust, 1.0));
|
||||
match result {
|
||||
Ok((document, mut warnings)) => {
|
||||
for w in &visible.warnings {
|
||||
@@ -1615,7 +1642,7 @@ mod tests {
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
let a = comparison_render(&mut renderer, &page, rect, 0.4, 1);
|
||||
let b = comparison_render(&mut vectorcraft_render::Renderer::new(), &vector, rect, 0.4, 1);
|
||||
assert!(compared(&a, &b, &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page");
|
||||
assert!(compared(&a, &b, &[], &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page");
|
||||
let frame = Frame { page: &page, page_rect: rect, rect, whole: false };
|
||||
let mut view = PageView::new(&frame);
|
||||
assert!(compare(&frame, &mut view, &vector).is_ok());
|
||||
|
||||
@@ -1352,3 +1352,81 @@ fn envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance()
|
||||
assert_eq!(texts, [("CHONKYCATS".to_string(), false)], "{:?}", r.warnings);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// An art style section with style `Comic`: a white fill offset and repeated by Transform under a
|
||||
/// red fill with its own blend mode, the characters at the bottom.
|
||||
fn comic_style() -> String {
|
||||
let basic = |name: &str, body: &str| {
|
||||
format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n")
|
||||
};
|
||||
let fill = |paint: &str| basic("Fill Style Filter", &format!("/FillStyle : 0 O\n{paint}\n0 1 0 0 0 Xy\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"));
|
||||
let compound = |name: &str, kids: &[String]| format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat());
|
||||
let rim = compound(
|
||||
"Chain Style Filter",
|
||||
&[
|
||||
basic("Adobe Offset Path", "3 /Real (ofst) ,\n0 /Int (jntp) ,\n"),
|
||||
fill("0 0 0 0 1 1 1 Xa"),
|
||||
basic("Adobe Transform", "6 /Int (numCopies) ,\n1 /Real (moveH_Pts) ,\n"),
|
||||
],
|
||||
);
|
||||
let red = compound(
|
||||
"Chain Style Filter",
|
||||
&[fill("0 1 1 0 1 0 0 Xa"), basic("Blend Style Filter", "/BlendStyle : 1 0.5 0 0 0 Xy\n/Def ;\n (BlendStyle) ,\n")],
|
||||
);
|
||||
let stack = compound("Stack Style Filter", &[red, rim, basic("Conduit Filter", "")]);
|
||||
format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Comic) /Name ,\n/ActiveStyle :\n{}%AI9_EndArtStyles\n", compound("Chain Style Filter", &[stack]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_with_a_stacked_art_style_comes_in_as_type_with_that_appearance() {
|
||||
// GUARDS: a style of more than one fill (or with effects on a fill) left the type as its drawn look,
|
||||
// outlines that can't be retyped (comic lettering made with Illustrator's Appearance panel).
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::AppearanceItem;
|
||||
let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
// The look: the style's drawing round where the type lies (its anchor is at (58.5, 61.5)).
|
||||
let drawing = "0 0 1 0 k\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf";
|
||||
let look = format!("u\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n{drawing}\nU\n9 () XW\n");
|
||||
let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect();
|
||||
let art = native_with_text(&(comic_style() + &layer("Art", true, &format!("{look}{own}1 (Comic) XW\n"))), &doc);
|
||||
let check = |d: &Document, notes: &[String]| {
|
||||
let mut texts = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
texts.push((t.plain_text(), n.appearance.clone()));
|
||||
}
|
||||
});
|
||||
let [(text, a)] = texts.as_slice() else { panic!("{texts:?} {notes:?}") };
|
||||
assert_eq!(text, "Bang");
|
||||
// Bottom first: the white rim with its effects, then the red fill at half opacity, multiplied.
|
||||
let [AppearanceItem::Fill(rim), AppearanceItem::Fill(red)] = a.items.as_slice() else { panic!("{a:?}") };
|
||||
assert_eq!(rim.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["path.offsetPath", "distort.transform"]);
|
||||
assert_eq!((red.blend, red.opacity), (BlendMode::Multiply, 0.5));
|
||||
assert!(!notes.iter().any(|w| w.contains("drawn look")), "{notes:?}");
|
||||
};
|
||||
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
|
||||
check(&d, ¬es);
|
||||
// From an EPS whose page draws Illustrator's rendering of the style (here, its look's rectangle):
|
||||
// the layers are used all the same, the style's area not being held to look like the page.
|
||||
let page = "0 0 1 0 setcmykcolor 54 56 moveto 100 56 lineto 100 80 lineto 54 80 lineto closepath fill";
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
|
||||
check(&r.document, &r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_type_without_a_matrix_is_placed_by_its_layout() {
|
||||
// GUARDS: Illustrator 24 leaves an unturned, unscaled point type's matrix out of its frame; such
|
||||
// stories weren't read, and the type was left out.
|
||||
let with = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let without = with.replace("/2 << /2 [ 1 0 0 1 0 0 ] >>", "/2 << >>");
|
||||
assert_ne!(with, without);
|
||||
let place = |doc: &str| {
|
||||
let art = native_with_text(&layer("Spare", false, &text_object(0)), doc);
|
||||
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
|
||||
let texts = text_of(&d.layers[0]);
|
||||
assert_eq!(texts.len(), 1, "{notes:?}");
|
||||
(texts[0].plain_text(), texts[0].xf)
|
||||
};
|
||||
assert_eq!(place(&without), place(&with));
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ mod ps;
|
||||
mod scene;
|
||||
mod tiff;
|
||||
|
||||
pub use import::{Imported, POSTSCRIPT_ERRORS, ai_alone, family_style, import, import_with, is_loss, layered_ai, page};
|
||||
pub use import::{Imported, POSTSCRIPT_ERRORS, ai_alone, family_style, import, import_trusting, import_with, is_loss, layered_ai, page};
|
||||
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai};
|
||||
pub use print::{PrintJob, PrintPage, print};
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user