mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #3419 from flamesquirrel/eps-styled-type
EPS/.ai editing data: type keeps stacked art styles as live appearance
This commit is contained in:
@@ -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};
|
||||
|
||||
|
||||
@@ -117,6 +117,32 @@ impl Look {
|
||||
}
|
||||
}
|
||||
|
||||
/// A gap between adjacent glyphs on a line.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Gap {
|
||||
/// Byte offset in the line's text of the glyph after the gap.
|
||||
at: usize,
|
||||
/// From the end of the glyph before to the start of the next (points).
|
||||
width: f64,
|
||||
/// The larger of the two glyphs' sizes (points).
|
||||
em: f64,
|
||||
/// Along the line, not turned along a curve.
|
||||
on_line: bool,
|
||||
}
|
||||
|
||||
/// The spacing a line's letters share (letter-spaced type), in ems: the gap a third of the way up
|
||||
/// from the narrowest, of three or more along the line. Never below zero, and at most half an em:
|
||||
/// glyphs set wider apart than that are words or columns, not tracked letters.
|
||||
fn common_gap(gaps: &[Gap]) -> f64 {
|
||||
let mut widths: Vec<f64> = gaps.iter().filter(|g| g.on_line).map(|g| g.width / g.em).filter(|w| w.is_finite()).collect();
|
||||
if widths.len() < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
let pivot = widths.len() / 3;
|
||||
let (_, common, _) = widths.select_nth_unstable_by(pivot, f64::total_cmp);
|
||||
common.clamp(0.0, 0.5)
|
||||
}
|
||||
|
||||
/// A line of type being gathered: runs of glyphs on one baseline.
|
||||
pub(crate) struct TextLine {
|
||||
/// The first glyph's placement.
|
||||
@@ -140,10 +166,13 @@ pub(crate) struct TextLine {
|
||||
/// Each glyph's ink box as drawn (document space), in drawing order; glyphs without ink are
|
||||
/// left out.
|
||||
pub ink: Vec<Rect>,
|
||||
/// The spaces read from gaps between glyphs, and the spaces the file draws between two glyphs
|
||||
/// that aren't spaces: their byte offset in the text, and the gap from the end of the glyph
|
||||
/// before to the start of the next (points).
|
||||
gaps: Vec<(usize, f64)>,
|
||||
/// The gaps between adjacent glyphs that may read as spaces ([`finish`](Self::finish)
|
||||
/// decides).
|
||||
gaps: Vec<Gap>,
|
||||
/// The spaces the file draws between two glyphs that aren't spaces: their byte offset in the
|
||||
/// text before the spaces read from gaps go in, and the gaps on both sides of it plus its
|
||||
/// advance (points).
|
||||
drawn_spaces: Vec<(usize, f64)>,
|
||||
/// A space the file drew after a glyph that isn't one, until the next glyph comes: its byte
|
||||
/// offset, and the gap before it plus its advance (points).
|
||||
drawn_space: Option<(usize, f64)>,
|
||||
@@ -184,6 +213,7 @@ impl TextLine {
|
||||
column: false,
|
||||
ink: vec![],
|
||||
gaps: vec![],
|
||||
drawn_spaces: vec![],
|
||||
drawn_space: None,
|
||||
advances: vec![],
|
||||
held: vec![],
|
||||
@@ -266,12 +296,8 @@ impl TextLine {
|
||||
if !on_line && !on_curve {
|
||||
return false;
|
||||
}
|
||||
// A gap wider than a fifth of an em reads as a space.
|
||||
if gap > size * 0.2 && !run.ends_with(' ') && !text.starts_with(' ') {
|
||||
run.push(' ');
|
||||
if on_line {
|
||||
self.gaps.push((bytes, gap));
|
||||
}
|
||||
if !run.ends_with(' ') && !text.starts_with(' ') {
|
||||
self.gaps.push(Gap { at: bytes, width: gap, em: size, on_line });
|
||||
}
|
||||
// A space the file draws between two glyphs spans the gaps on both sides of it.
|
||||
let drawn = self.drawn_space.take();
|
||||
@@ -279,7 +305,7 @@ impl TextLine {
|
||||
if text == " " && !run.ends_with(' ') {
|
||||
self.drawn_space = Some((bytes, gap + advance));
|
||||
} else if let Some((b, before)) = drawn.filter(|_| !text.starts_with(' ')) {
|
||||
self.gaps.push((b, before + gap));
|
||||
self.drawn_spaces.push((b, before + gap));
|
||||
}
|
||||
}
|
||||
if last.takes(look) {
|
||||
@@ -358,6 +384,35 @@ impl TextLine {
|
||||
let first = runs.next()?;
|
||||
let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style);
|
||||
t.runs.extend(runs);
|
||||
// A gap wider than a fifth of an em, past the spacing the line's letters share, reads as a
|
||||
// space. Letter spacing is read only in text left in the file's order.
|
||||
let common = if t.para.direction.is_none() { common_gap(&self.gaps) } else { 0.0 };
|
||||
let mut spaces = vec![];
|
||||
for gap in &self.gaps {
|
||||
// A word space set at exactly a fifth of an em still counts, whatever the rounding.
|
||||
if gap.width / gap.em - common <= 0.199 {
|
||||
continue;
|
||||
}
|
||||
// After the earlier spaces, at the end of the run before it (the run it follows).
|
||||
let at = gap.at + spaces.len();
|
||||
let mut offset = 0;
|
||||
let Some(run) = t.runs.iter_mut().find(|run| {
|
||||
offset += run.text.len();
|
||||
at <= offset
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
let at = at - (offset - run.text.len());
|
||||
if !run.text.is_char_boundary(at) {
|
||||
continue;
|
||||
}
|
||||
run.text.insert(at, ' ');
|
||||
spaces.push((gap.at, gap.width, gap.on_line));
|
||||
}
|
||||
// Each by its offset in the text with the spaces in.
|
||||
let mut gaps: Vec<(usize, f64)> = spaces.iter().enumerate().filter(|(_, s)| s.2).map(|(i, s)| (s.0 + i, s.1)).collect();
|
||||
gaps.extend(self.drawn_spaces.iter().map(|&(b, width)| (b + spaces.iter().filter(|s| s.0 < b).count(), width)));
|
||||
gaps.sort_by_key(|&(b, _)| b);
|
||||
if self.upright.is_none() {
|
||||
to_logical(&mut t);
|
||||
}
|
||||
@@ -420,8 +475,7 @@ impl TextLine {
|
||||
// The spaces read from gaps or drawn between glyphs keep their own place only in text
|
||||
// left in the file's order.
|
||||
let total: usize = t.runs.iter().map(|r| r.text.len()).sum();
|
||||
let gaps: Vec<(usize, f64)> =
|
||||
if t.para.direction.is_none() { self.gaps.into_iter().filter(|(b, _)| *b < total).collect() } else { vec![] };
|
||||
let gaps: Vec<(usize, f64)> = if t.para.direction.is_none() { gaps.into_iter().filter(|(b, _)| *b < total).collect() } else { vec![] };
|
||||
let (tracking, spaces) = spacing(&laid, &gaps, length - natural, chars, size);
|
||||
t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces);
|
||||
// Tracked: laid out again for its bounds.
|
||||
|
||||
@@ -309,6 +309,19 @@ fn text_becomes_point_type() {
|
||||
assert!(all(&r.document).iter().any(|n| n.name.as_deref() == Some("<Text Outlines>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_spacing_does_not_become_spaces() {
|
||||
for size in [12.0, 13.0, 14.0, 15.0] {
|
||||
for tracking in [150.0, 180.0, 200.0, 220.0, 250.0] {
|
||||
let spacing = size * tracking / 1000.0;
|
||||
let content = format!("BT /F1 {size} Tf {spacing} Tc 10 40 Td (OPEN DAILY) Tj ET");
|
||||
let t = texts(&open(&one_page(&content, HELVETICA, &[])));
|
||||
assert_eq!(t.len(), 1, "size {size}, tracking {tracking}: {t:?}");
|
||||
assert_eq!(t[0].plain_text(), "OPEN DAILY", "size {size}, tracking {tracking}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Invisible text (rendering mode 3: a scanned page's recognized text, laid over the scan) is
|
||||
/// type with neither fill nor stroke, and the visible text keeps its paint. On a line with
|
||||
/// stroked text it is a run of its own, not stroked. As outlines it would draw nothing: it is left
|
||||
|
||||
@@ -101,6 +101,43 @@ fn letter_spacing_in_the_pdf_becomes_tracking() {
|
||||
assert!((ink_width(&t[0]) - expected).abs() < 0.3, "{} vs {expected}", ink_width(&t[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_tracking_does_not_turn_gaps_into_spaces() {
|
||||
let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap();
|
||||
for size in [12.0, 13.0, 14.0, 15.0] {
|
||||
for tracking in [150.0, 180.0, 200.0, 220.0, 250.0] {
|
||||
let mut x = 20.0;
|
||||
let mut pieces = vec![];
|
||||
for (word_index, word) in ["OPEN", "DAILY", "TYPE"].iter().enumerate() {
|
||||
for character in word.chars() {
|
||||
pieces.push((x, character.to_string()));
|
||||
x += font.advance(font.glyph_for(character)) as f32 / font.units_per_em() as f32 * size + size * tracking / 1000.0;
|
||||
}
|
||||
if word_index < 2 {
|
||||
x += font.advance(font.glyph_for(' ')) as f32 / font.units_per_em() as f32 * size + size * tracking / 1000.0;
|
||||
}
|
||||
}
|
||||
let lines: Vec<_> = pieces.iter().map(|(x, text)| (*x, 60.0, size, text.as_str())).collect();
|
||||
let d = import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document;
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 1, "size {size}, tracking {tracking}: {t:?}");
|
||||
assert_eq!(t[0].plain_text(), "OPEN DAILY TYPE", "size {size}, tracking {tracking}");
|
||||
assert!((t[0].first_style().tracking - f64::from(tracking)).abs() <= 10.0, "size {size}, tracking {tracking}: {:?}", t[0].runs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Glyphs set an em or more apart, each on its own, are words or columns: every gap stays a space,
|
||||
/// however evenly they are spaced.
|
||||
#[test]
|
||||
fn evenly_wide_gaps_stay_spaces() {
|
||||
let lines: Vec<_> = ["1", "2", "3", "4"].iter().enumerate().map(|(i, text)| (20.0 + i as f32 * 18.0, 60.0, 12.0, *text)).collect();
|
||||
let d = import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document;
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 1, "{t:?}");
|
||||
assert_eq!(t[0].plain_text(), "1 2 3 4");
|
||||
}
|
||||
|
||||
/// Embedded font data is untrusted: anything that isn't a CID-keyed CFF is no table, never a panic.
|
||||
#[test]
|
||||
fn cid_tables_ignore_fonts_that_are_not_cid_keyed_cff() {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user