mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 10:01:58 +00:00
Merge main into feat/image-color-mask
This commit is contained in:
Generated
+2
-2
@@ -1081,7 +1081,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1108,7 +1108,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-vectorcraft-format"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"serde",
|
||||
|
||||
+2
-2
@@ -21,11 +21,11 @@ craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a637
|
||||
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] }
|
||||
# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON.
|
||||
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
# Native Affinity documents (craft-libs#914).
|
||||
craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
vectorcraft-color = { path = "crates/color" }
|
||||
|
||||
@@ -202,6 +202,13 @@ impl AngleProfile {
|
||||
if v.is_finite() { vectorcraft_geom::normalize_deg(v) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The angle at `t` (0..1) as [`Self::at`] reads it, but in the samples' own running degrees,
|
||||
/// so the whole turns the curve makes along the stroke are kept; 0 without any samples.
|
||||
pub fn unwrapped_at(&self, t: f64) -> f64 {
|
||||
let v = interpolated(&self.points, t).unwrap_or(0.0);
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
}
|
||||
|
||||
/// A profile from (position, degrees) samples in order along the stroke: non-finite ones are
|
||||
/// dropped, each sample is joined to the one before it the shorter way round, and the curve is
|
||||
/// simplified to within 1° and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample.
|
||||
|
||||
+460
-17
@@ -5,7 +5,8 @@
|
||||
//! winding and to the start points that keep them from twisting, or to the anchors the Blend
|
||||
//! tool clicked.
|
||||
//! - **Paint**: colours, gradients (stops resampled to a common set when their counts differ),
|
||||
//! opacity, stroke weight, dashes, miter limit and width profiles interpolate; what can't
|
||||
//! opacity, stroke weight, dashes, miter limit, width profiles and the pen pressure, tilt,
|
||||
//! bearing and rotation that brushes read interpolate; what can't
|
||||
//! (caps, joins, brushes, fonts, symbols, text content) switches halfway.
|
||||
//! - **Groups** pair their children in stacking order; the children one group has more of grow
|
||||
//! out of the other group's centre. Compound paths stay compound paths.
|
||||
@@ -22,7 +23,7 @@ use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv};
|
||||
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
|
||||
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile};
|
||||
use crate::appearance::{AngleProfile, Appearance, AppearanceItem, Dash, PressureProfile, StrokeLayer, WidthProfile};
|
||||
use crate::live::{BlendOrientation, BlendSpacing, BlendSpec};
|
||||
use crate::node::{Node, NodeId, NodeKind};
|
||||
use crate::text::TextObject;
|
||||
@@ -235,7 +236,8 @@ fn lcm(a: usize, b: usize) -> usize {
|
||||
a.checked_div(x).map_or(0, |q| q * b)
|
||||
}
|
||||
|
||||
/// Interpolate width profiles (none is uniform) at every position either has a width point.
|
||||
/// Interpolate width profiles (none is uniform) at every position either has a width point,
|
||||
/// keeping their discontinuous points (a position where either steps gives two points).
|
||||
fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> Option<WidthProfile> {
|
||||
if x.is_none() && y.is_none() {
|
||||
return None;
|
||||
@@ -244,20 +246,135 @@ fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> O
|
||||
let (x, y) = (x.unwrap_or(&uniform), y.unwrap_or(&uniform));
|
||||
let mut at: Vec<f64> = x.points.iter().chain(&y.points).map(|p| p.0).filter(|p| p.is_finite()).collect();
|
||||
at.sort_by(f64::total_cmp);
|
||||
at.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
|
||||
let points = at
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let ((l1, r1), (l2, r2)) = (x.at(p), y.at(p));
|
||||
(p, lerp_f64(l1, l2, t), lerp_f64(r1, r2, t))
|
||||
})
|
||||
.collect();
|
||||
// Within the distance `around` reads a discontinuous point at, so a point near one keeps it.
|
||||
at.dedup_by(|a, b| (*a - *b).abs() <= 1e-7);
|
||||
// Each profile is a smooth curve through its own points: samples between the positions keep
|
||||
// both curves' shapes where the other profile's points and steps change the tangents. Two
|
||||
// profiles with points at the same positions need none (so a preset on both keys stays it).
|
||||
let same_positions = x.points.len() == y.points.len() && x.points.iter().zip(&y.points).all(|(p, q)| (p.0 - q.0).abs() <= 1e-7);
|
||||
let per_span = if same_positions { 1 } else { (4096 / at.len().max(1)).clamp(1, 8) };
|
||||
let mut points = Vec::with_capacity(at.len() * per_span + 2);
|
||||
let mix = |p: f64, (l1, r1): (f64, f64), (l2, r2): (f64, f64)| (p, lerp_f64(l1, l2, t), lerp_f64(r1, r2, t));
|
||||
for (k, &p) in at.iter().enumerate() {
|
||||
let ((xb, xa), (yb, ya)) = (x.around(p), y.around(p));
|
||||
points.push(mix(p, xb, yb));
|
||||
// A discontinuous point ends the curve on each side even where both widths match.
|
||||
if steps_at(x, p) || steps_at(y, p) {
|
||||
points.push(mix(p, xa, ya));
|
||||
}
|
||||
if let Some(&q) = at.get(k + 1) {
|
||||
for j in 1..per_span {
|
||||
let f = p + (q - p) * j as f64 / per_span as f64;
|
||||
points.push(mix(f, x.at(f), y.at(f)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(WidthProfile { points })
|
||||
}
|
||||
|
||||
/// Interpolate a stroke's paint, weight, opacity, miter limit, dashes, arrowhead scale and width
|
||||
/// profile into `o` (a copy of the nearer stroke, so caps, joins, alignment, arrowheads and
|
||||
/// brushes switch halfway).
|
||||
/// Whether width profile `w` has a discontinuous point at `p` (two points there, within `1e-7`,
|
||||
/// as [`WidthProfile::around`] reads them).
|
||||
fn steps_at(w: &WidthProfile, p: f64) -> bool {
|
||||
let i = w.points.partition_point(|q| q.0 < p - 1e-7);
|
||||
w.points.get(i..).unwrap_or_default().iter().take_while(|q| q.0 <= p + 1e-7).count() > 1
|
||||
}
|
||||
|
||||
/// The positions along the stroke where either of two pen recordings has a sample, in order.
|
||||
fn sample_positions(x: Option<&[(f64, f64)]>, y: Option<&[(f64, f64)]>) -> Vec<f64> {
|
||||
let mut at: Vec<f64> = x.unwrap_or_default().iter().chain(y.unwrap_or_default()).map(|p| p.0).filter(|p| p.is_finite()).collect();
|
||||
at.sort_by(f64::total_cmp);
|
||||
// Within the distance `step_after` reads a step at, so a sample near a step keeps the step.
|
||||
at.dedup_by(|a, b| (*a - *b).abs() <= 1e-9);
|
||||
if at.is_empty() {
|
||||
at = vec![0.0, 1.0];
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// The value pen recording `p` steps to at position `f`, where two or more of its samples share
|
||||
/// that position (reading at `f` gives the first of them); None where it doesn't step.
|
||||
fn step_after(p: Option<&[(f64, f64)]>, f: f64) -> Option<f64> {
|
||||
let p = p?;
|
||||
let i = p.partition_point(|q| q.0 < f - 1e-9);
|
||||
let j = p.partition_point(|q| q.0 <= f + 1e-9).checked_sub(1)?;
|
||||
(j > i && (p.get(i)?.0 - f).abs() <= 1e-9).then(|| p.get(j).map(|q| q.1)).flatten()
|
||||
}
|
||||
|
||||
/// Two pen recordings read at every position either has a sample, as (position, x's value, y's
|
||||
/// value): `read_x` and `read_y` read them, and `clean` makes a stepped sample's value one they
|
||||
/// would read. A position where either steps gives two, before and after the step.
|
||||
fn paired_samples(
|
||||
x: Option<&[(f64, f64)]>,
|
||||
y: Option<&[(f64, f64)]>,
|
||||
read_x: impl Fn(f64) -> f64,
|
||||
read_y: impl Fn(f64) -> f64,
|
||||
clean: impl Fn(f64) -> f64,
|
||||
) -> Vec<(f64, f64, f64)> {
|
||||
let mut out = Vec::new();
|
||||
for f in sample_positions(x, y) {
|
||||
let (a, b) = (read_x(f), read_y(f));
|
||||
out.push((f, a, b));
|
||||
let (sa, sb) = (step_after(x, f), step_after(y, f));
|
||||
if sa.is_some() || sb.is_some() {
|
||||
out.push((f, sa.map_or(a, &clean), sb.map_or(b, &clean)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Interpolate pen pressure or tilt recordings at every position either has a sample, keeping
|
||||
/// their steps. A stroke without one reads as [`PressureProfile::MID`], as brushes read it; None
|
||||
/// when neither has one.
|
||||
fn lerp_pressure(x: Option<&PressureProfile>, y: Option<&PressureProfile>, t: f64) -> Option<PressureProfile> {
|
||||
if x.is_none() && y.is_none() {
|
||||
return None;
|
||||
}
|
||||
let read = |p: Option<&PressureProfile>, f: f64| p.map_or(PressureProfile::MID, |p| p.at(f));
|
||||
let clean = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { PressureProfile::MID };
|
||||
let samples = paired_samples(x.map(|p| p.points.as_slice()), y.map(|p| p.points.as_slice()), |f| read(x, f), |f| read(y, f), clean);
|
||||
PressureProfile::from_samples(samples.into_iter().map(|(f, a, b)| (f, lerp_f64(a, b, t))))
|
||||
}
|
||||
|
||||
/// Interpolate pen angle recordings (bearing or barrel rotation) at every position either has a
|
||||
/// sample: the steps turn from one key's angle to the other's the shorter way round at the
|
||||
/// stroke's start, and keep the whole turns each recording makes along the stroke. A stroke
|
||||
/// without one reads as 0°, as brushes read it; None when neither has one.
|
||||
fn lerp_angle(x: Option<&AngleProfile>, y: Option<&AngleProfile>, t: f64) -> Option<AngleProfile> {
|
||||
if x.is_none() && y.is_none() {
|
||||
return None;
|
||||
}
|
||||
let read = |p: Option<&AngleProfile>, f: f64| p.map_or(0.0, |p| p.unwrapped_at(f));
|
||||
let clean = |v: f64| if v.is_finite() { v } else { 0.0 };
|
||||
let pairs = paired_samples(x.map(|p| p.points.as_slice()), y.map(|p| p.points.as_slice()), |f| read(x, f), |f| read(y, f), clean);
|
||||
// Whole turns that bring the two angles within half a turn of each other at the first position.
|
||||
let d0 = pairs.first().map_or(0.0, |&(_, a, b)| b - a);
|
||||
let turns = vectorcraft_geom::normalize_deg(d0) - d0;
|
||||
let value = |a: f64, b: f64| a + (b - a + turns) * t;
|
||||
// Both curves are straight between two positions; samples are added where the steps turn
|
||||
// through 90° or more between them, since `AngleProfile::from_samples` joins neighbours the
|
||||
// shorter way round. A document's recordings can be long, so the added samples are limited.
|
||||
let mut budget = 4 * PressureProfile::MAX_POINTS;
|
||||
let mut samples = Vec::with_capacity(pairs.len());
|
||||
let mut prev: Option<(f64, f64)> = None;
|
||||
for (f, a, b) in pairs {
|
||||
let v = value(a, b);
|
||||
if let Some((pf, pv)) = prev.filter(|p| f - p.0 > 1e-9) {
|
||||
let parts = ((v - pv).abs() / 90.0).ceil().min((budget + 1) as f64) as usize;
|
||||
for k in 1..parts {
|
||||
let g = pf + (f - pf) * k as f64 / parts as f64;
|
||||
samples.push((g, value(read(x, g), read(y, g))));
|
||||
}
|
||||
budget -= parts.saturating_sub(1).min(budget);
|
||||
}
|
||||
samples.push((f, v));
|
||||
prev = Some((f, v));
|
||||
}
|
||||
AngleProfile::from_samples(samples)
|
||||
}
|
||||
|
||||
/// Interpolate a stroke's paint, weight, opacity, miter limit, dashes, arrowhead scale, width
|
||||
/// profile and the pen pressure, tilt, bearing and rotation its brush reads into `o` (a copy of
|
||||
/// the nearer stroke, so caps, joins, alignment, arrowheads and brushes switch halfway).
|
||||
fn lerp_stroke(o: &mut StrokeLayer, x: &StrokeLayer, y: &StrokeLayer, t: f64) {
|
||||
let tf = t as f32;
|
||||
o.paint = lerp_paint(&x.paint, &y.paint, tf);
|
||||
@@ -267,6 +384,10 @@ fn lerp_stroke(o: &mut StrokeLayer, x: &StrokeLayer, y: &StrokeLayer, t: f64) {
|
||||
o.dash = lerp_dash(x.dash.as_ref(), y.dash.as_ref(), t);
|
||||
o.arrow_scale = (lerp_f64(x.arrow_scale.0, y.arrow_scale.0, t), lerp_f64(x.arrow_scale.1, y.arrow_scale.1, t));
|
||||
o.profile = lerp_profile(x.profile.as_ref(), y.profile.as_ref(), t);
|
||||
o.pressure = lerp_pressure(x.pressure.as_ref(), y.pressure.as_ref(), t);
|
||||
o.tilt = lerp_pressure(x.tilt.as_ref(), y.tilt.as_ref(), t);
|
||||
o.bearing = lerp_angle(x.bearing.as_ref(), y.bearing.as_ref(), t);
|
||||
o.rotation = lerp_angle(x.rotation.as_ref(), y.rotation.as_ref(), t);
|
||||
}
|
||||
|
||||
/// Interpolate appearance stacks: item by item when their structure matches, else the top fill
|
||||
@@ -390,6 +511,161 @@ fn reverse_closed(sp: &mut SubPath) {
|
||||
sp.anchors.rotate_right(1);
|
||||
}
|
||||
|
||||
/// How a key's subpaths were turned for its blend, so that its stroke's profiles, pen
|
||||
/// recordings and arrowheads turn with it: position `u` along the turned subpath is position
|
||||
/// `start + u` (`start - u` when `reversed`) along the key's own, wrapping round a closed one.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Orient {
|
||||
start: f64,
|
||||
reversed: bool,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl Orient {
|
||||
fn of(sp: &SubPath) -> Self {
|
||||
Self { closed: sp.closed, ..Self::default() }
|
||||
}
|
||||
|
||||
/// The subpath now starts at the fraction `f` of its length.
|
||||
fn rotate(&mut self, f: f64) {
|
||||
self.start += if self.reversed { -f } else { f };
|
||||
}
|
||||
|
||||
/// The subpath now runs the other way.
|
||||
fn reverse(&mut self) {
|
||||
self.start += if self.reversed { -1.0 } else { 1.0 };
|
||||
self.reversed = !self.reversed;
|
||||
}
|
||||
|
||||
/// As [`start_at`] turns `sp` (call it before).
|
||||
fn start_at(&mut self, sp: &SubPath, i: usize) {
|
||||
let n = sp.anchors.len();
|
||||
if i >= n {
|
||||
return;
|
||||
}
|
||||
if sp.closed {
|
||||
self.rotate(arc_fraction(sp, i));
|
||||
} else if i + 1 == n && n > 1 {
|
||||
self.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a closed subpath now starts, as a fraction of its length; 0 for an open one.
|
||||
fn seam(&self) -> f64 {
|
||||
let s = if self.closed { self.start.rem_euclid(1.0) } else { 0.0 };
|
||||
if s > 1e-9 && s < 1.0 - 1e-9 { s } else { 0.0 }
|
||||
}
|
||||
|
||||
fn is_identity(&self) -> bool {
|
||||
!self.reversed && self.seam() == 0.0
|
||||
}
|
||||
|
||||
/// Whether two subpaths were turned alike, so one stroke along both turns the same way.
|
||||
fn same_turn(&self, o: &Self) -> bool {
|
||||
self.reversed == o.reversed && (self.seam() - o.seam()).abs() < 1e-9
|
||||
}
|
||||
}
|
||||
|
||||
/// How far along `sp` its anchor `i` sits, as a fraction of its length.
|
||||
fn arc_fraction(sp: &SubPath, i: usize) -> f64 {
|
||||
let pos = sp.anchor_arc_positions();
|
||||
match (pos.get(i), pos.last()) {
|
||||
(Some(&at), Some(&total)) if total.is_finite() && total > 1e-12 => (at / total).clamp(0.0, 1.0),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Samples (position, value) along a key's subpath, read along the subpath turned as `o` says.
|
||||
/// A closed subpath starting elsewhere starts the samples there too: the ones before it move
|
||||
/// past the seam (`wrap` applied), which keeps any step the seam makes, and a value between
|
||||
/// samples is read with `at`. A reversed subpath then reads them backwards (`flip` applied).
|
||||
fn reoriented<V: Copy>(points: &[(f64, V)], o: Orient, at: impl Fn(f64) -> V, wrap: impl Fn(V) -> V, flip: impl Fn(V) -> V) -> Vec<(f64, V)> {
|
||||
let s = o.seam();
|
||||
let mut out = points.to_vec();
|
||||
if s > 0.0
|
||||
&& let (Some(&(_, first)), Some(&(_, last))) = (points.first(), points.last())
|
||||
{
|
||||
let i = points.partition_point(|q| q.0 < s - 1e-9);
|
||||
let j = points.partition_point(|q| q.0 <= s + 1e-9).max(i);
|
||||
let (before, after) = match points.get(i..j) {
|
||||
Some([p, .., q]) => (p.1, q.1),
|
||||
Some([p]) => (p.1, p.1),
|
||||
_ => (at(s), at(s)),
|
||||
};
|
||||
out = Vec::with_capacity(points.len() + 4);
|
||||
out.push((0.0, after));
|
||||
out.extend(points.get(j..).unwrap_or_default().iter().map(|&(t, v)| (t - s, v)));
|
||||
out.push((1.0 - s, last));
|
||||
out.push((1.0 - s, wrap(first)));
|
||||
out.extend(points.get(..i).unwrap_or_default().iter().map(|&(t, v)| (t + 1.0 - s, wrap(v))));
|
||||
out.push((1.0, wrap(before)));
|
||||
}
|
||||
if o.reversed {
|
||||
out = out.into_iter().rev().map(|(t, v)| (1.0 - t, flip(v))).collect();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A key's stroke read along its subpath turned as `o` says: its width profile (whose sides swap
|
||||
/// when reversed), pen pressure, tilt, bearing and rotation, and on a reversed open path its
|
||||
/// arrowheads.
|
||||
fn reorient_stroke(s: &mut StrokeLayer, o: Orient) {
|
||||
if o.is_identity() {
|
||||
return;
|
||||
}
|
||||
let same = |v: f64| v;
|
||||
if let Some(p) = &mut s.profile {
|
||||
let mut pts: Vec<(f64, (f64, f64))> = p.points.iter().map(|&(t, l, r)| (t, (l, r))).collect();
|
||||
// A width profile is a smooth curve through its points: where the new start splits a span,
|
||||
// samples of that span keep its curve once its ends become ends of the profile.
|
||||
let seam = o.seam();
|
||||
let i = p.points.partition_point(|q| q.0 < seam);
|
||||
if seam > 0.0
|
||||
&& let (Some(a), Some(b)) = (i.checked_sub(1).and_then(|j| p.points.get(j)), p.points.get(i))
|
||||
&& seam - a.0 > 1e-9
|
||||
&& b.0 - seam > 1e-9
|
||||
{
|
||||
const SAMPLES: usize = 32;
|
||||
let (a, b) = (a.0, b.0);
|
||||
let extra: Vec<(f64, (f64, f64))> =
|
||||
(1..SAMPLES).map(|k| a + (b - a) * k as f64 / SAMPLES as f64).filter(|f| (f - seam).abs() > 1e-9).map(|f| (f, p.at(f))).collect();
|
||||
pts.splice(i..i, extra);
|
||||
}
|
||||
let out = reoriented(&pts, o, |f| p.at(f), |v| v, |(l, r)| (r, l));
|
||||
p.points = out.into_iter().map(|(t, (l, r))| (t, l, r)).collect();
|
||||
}
|
||||
for p in [&mut s.pressure, &mut s.tilt].into_iter().flatten() {
|
||||
let out = reoriented(&p.points, o, |f| p.at(f), same, same);
|
||||
p.points = out;
|
||||
}
|
||||
for p in [&mut s.bearing, &mut s.rotation].into_iter().flatten() {
|
||||
// The samples moved past the seam carry the whole turns the recording makes along the
|
||||
// stroke, so the angle does not jump by them there.
|
||||
let turns = match (p.points.first(), p.points.last()) {
|
||||
(Some(a), Some(b)) if (b.1 - a.1).is_finite() => ((b.1 - a.1) / 360.0).round() * 360.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let out = reoriented(&p.points, o, |f| p.unwrapped_at(f), |v| v + turns, same);
|
||||
p.points = out;
|
||||
}
|
||||
if o.reversed && !o.closed {
|
||||
std::mem::swap(&mut s.start_arrow, &mut s.end_arrow);
|
||||
s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` with every stroke read along its subpaths turned as `o` says.
|
||||
fn reorient_strokes(n: &mut Node, o: Orient) {
|
||||
if o.is_identity() {
|
||||
return;
|
||||
}
|
||||
for it in n.appearance.items.iter_mut() {
|
||||
if let AppearanceItem::Stroke(s) = it {
|
||||
reorient_stroke(s, o);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Anchor positions of a subpath relative to its box, scaled to unit size.
|
||||
fn normalised(sp: &SubPath) -> Vec<Vec2> {
|
||||
let pts = sp.anchors.iter().map(|a| a.p);
|
||||
@@ -427,6 +703,8 @@ fn best_rotation(a: &SubPath, b: &SubPath) -> usize {
|
||||
pub struct PathPair {
|
||||
a: Vec<SubPath>,
|
||||
b: Vec<SubPath>,
|
||||
/// How each path's subpaths were turned, where all of them turned alike (else unturned).
|
||||
orient: (Orient, Orient),
|
||||
}
|
||||
|
||||
impl PathPair {
|
||||
@@ -438,6 +716,7 @@ impl PathPair {
|
||||
let cb = b.bounds().map(|r| r.center()).unwrap_or_default();
|
||||
let degenerate = |like: &SubPath, c: Point| SubPath::new(vec![Anchor::corner(c); like.anchors.len().max(1)], like.closed);
|
||||
let (mut va, mut vb) = (Vec::with_capacity(n), Vec::with_capacity(n));
|
||||
let mut orient = (Orient::default(), Orient::default());
|
||||
for i in 0..n {
|
||||
let (mut sa, mut sb) = match (a.subpaths.get(i), b.subpaths.get(i)) {
|
||||
(Some(x), Some(y)) => (x.clone(), y.clone()),
|
||||
@@ -445,17 +724,21 @@ impl PathPair {
|
||||
(None, Some(y)) => (degenerate(y, ca), y.clone()),
|
||||
(None, None) => continue,
|
||||
};
|
||||
let (mut oa, mut ob) = (Orient::of(&sa), Orient::of(&sb));
|
||||
let explicit = i == 0 && (starts.0.is_some() || starts.1.is_some());
|
||||
if explicit {
|
||||
if let Some(s) = starts.0 {
|
||||
oa.start_at(&sa, s);
|
||||
start_at(&mut sa, s);
|
||||
}
|
||||
if let Some(s) = starts.1 {
|
||||
ob.start_at(&sb, s);
|
||||
start_at(&mut sb, s);
|
||||
}
|
||||
}
|
||||
let closed = sa.closed && sb.closed && sa.anchors.len() > 2 && sb.anchors.len() > 2;
|
||||
if closed && (sa.area() * sb.area()) < 0.0 {
|
||||
ob.reverse();
|
||||
reverse_closed(&mut sb);
|
||||
}
|
||||
let m = sa.anchors.len().max(sb.anchors.len());
|
||||
@@ -463,12 +746,26 @@ impl PathPair {
|
||||
grow(&mut sb, m);
|
||||
if closed && !explicit {
|
||||
let r = best_rotation(&sa, &sb);
|
||||
if r != 0 {
|
||||
ob.rotate(arc_fraction(&sb, r));
|
||||
}
|
||||
sb.anchors.rotate_left(r);
|
||||
}
|
||||
// One stroke runs along every subpath of a key, each reading it by its own length, so
|
||||
// it turns only when all of the key's subpaths turned alike.
|
||||
if i == 0 {
|
||||
orient = (oa, ob);
|
||||
}
|
||||
if i < a.subpaths.len() && !orient.0.same_turn(&oa) {
|
||||
orient.0 = Orient::default();
|
||||
}
|
||||
if i < b.subpaths.len() && !orient.1.same_turn(&ob) {
|
||||
orient.1 = Orient::default();
|
||||
}
|
||||
va.push(sa);
|
||||
vb.push(sb);
|
||||
}
|
||||
Self { a: va, b: vb }
|
||||
Self { a: va, b: vb, orient }
|
||||
}
|
||||
|
||||
/// The path at `t` (0 = `a`, 1 = `b`).
|
||||
@@ -583,7 +880,12 @@ impl Lerp {
|
||||
_ => Shape::Other,
|
||||
},
|
||||
};
|
||||
Self { a: a.clone(), b: b.clone(), shape }
|
||||
let (mut a, mut b) = (a.clone(), b.clone());
|
||||
if let Shape::Path { pair, .. } = &shape {
|
||||
reorient_strokes(&mut a, pair.orient.0);
|
||||
reorient_strokes(&mut b, pair.orient.1);
|
||||
}
|
||||
Self { a, b, shape }
|
||||
}
|
||||
|
||||
/// The object at `t` (0 = `a`, 1 = `b`), with id 0.
|
||||
@@ -1485,7 +1787,7 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
|
||||
// Even position of the step, then where the easing puts its shape and its colours.
|
||||
let u = j as f64 / (n + 1) as f64;
|
||||
// Easing needs a spine: a blend made without one keeps its steps evenly spaced.
|
||||
let eased = rail.is_some() && !spec.no_spine;
|
||||
let eased = rail.is_some() && spec.eases_along_spine();
|
||||
let (t, tc) = (if eased { spec.ease.apply(u) } else { u }, spec.color_position(u));
|
||||
let mut s = lerp.at_eased(t, tc);
|
||||
if let Some(r) = &rail {
|
||||
@@ -1500,6 +1802,7 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::appearance::Arrowhead;
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
#[test]
|
||||
@@ -1755,6 +2058,146 @@ mod tests {
|
||||
assert_eq!(p.at(0.5), (1.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_pressure_tilt_and_angles_interpolate_for_brushes() {
|
||||
let stroked = |pressure: Option<f64>, tilt: Option<f64>, bearing: Option<f64>| {
|
||||
let mut ap = Appearance::default();
|
||||
ap.set_stroke(Paint::solid(Color::BLACK));
|
||||
if let Some(s) = ap.stroke_mut() {
|
||||
s.brush = Some("Pressure Round".into());
|
||||
s.pressure = pressure.map(|p| PressureProfile { points: vec![(0.0, p), (1.0, p)] });
|
||||
s.tilt = tilt.map(|p| PressureProfile { points: vec![(0.0, p), (1.0, p)] });
|
||||
s.bearing = bearing.map(|a| AngleProfile { points: vec![(0.0, a), (1.0, a)] });
|
||||
}
|
||||
ap
|
||||
};
|
||||
let at = |ap: &Appearance, f: f64| {
|
||||
let s = ap.stroke().cloned().unwrap_or_else(|| panic!("a stroke"));
|
||||
let read = |p: Option<&PressureProfile>| p.map_or(PressureProfile::MID, |p| p.at(f));
|
||||
(read(s.pressure.as_ref()), read(s.tilt.as_ref()), s.bearing.as_ref().map_or(0.0, |p| p.at(f)))
|
||||
};
|
||||
let (light, heavy) = (stroked(Some(0.0), Some(0.2), Some(170.0)), stroked(Some(1.0), Some(0.6), Some(-170.0)));
|
||||
let (p, tilt, bearing) = at(&lerp_appearance(&light, &heavy, 0.25), 0.5);
|
||||
assert!((p - 0.25).abs() < 1e-9 && (tilt - 0.3).abs() < 1e-9, "a quarter of the way: {p} {tilt}");
|
||||
assert!((bearing - 175.0).abs() < 1e-9, "the angle turns the shorter way round, through 180°: {bearing}");
|
||||
assert!((at(&lerp_appearance(&light, &heavy, 0.5), 0.5).2.abs() - 180.0).abs() < 1e-9);
|
||||
// A stroke drawn without a recording reads as half pressure, half tilt and 0°.
|
||||
let plain = stroked(None, None, None);
|
||||
let (p, tilt, bearing) = at(&lerp_appearance(&heavy, &plain, 0.5), 0.0);
|
||||
assert!((p - 0.75).abs() < 1e-9 && (tilt - 0.55).abs() < 1e-9 && (bearing + 85.0).abs() < 1e-9, "{p} {tilt} {bearing}");
|
||||
// Profiles that vary along the stroke interpolate at each position.
|
||||
let mut ramp = stroked(None, None, None);
|
||||
if let Some(s) = ramp.stroke_mut() {
|
||||
s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] });
|
||||
}
|
||||
let mid = lerp_appearance(&ramp, &heavy, 0.5);
|
||||
assert!((at(&mid, 0.0).0 - 0.5).abs() < 1e-9 && (at(&mid, 0.5).0 - 0.75).abs() < 1e-9, "{:?}", mid.stroke().map(|s| &s.pressure));
|
||||
// A step in a recording (two samples at one position) stays a step.
|
||||
let mut stepped = stroked(None, None, None);
|
||||
if let Some(s) = stepped.stroke_mut() {
|
||||
s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (0.5, 0.0), (0.5, 1.0), (1.0, 1.0)] });
|
||||
}
|
||||
let mid = lerp_appearance(&stepped, &stepped, 0.5);
|
||||
assert!(at(&mid, 0.25).0.abs() < 1e-9 && (at(&mid, 0.75).0 - 1.0).abs() < 1e-9, "{:?}", mid.stroke().map(|s| &s.pressure));
|
||||
// A whole turn recorded along a stroke stays a whole turn in the steps.
|
||||
let mut turning = stroked(None, None, None);
|
||||
if let Some(s) = turning.stroke_mut() {
|
||||
s.rotation = Some(AngleProfile { points: vec![(0.0, 0.0), (1.0, 360.0)] });
|
||||
}
|
||||
let rot = |ap: &Appearance, f: f64| ap.stroke().and_then(|s| s.rotation.as_ref()).map_or(0.0, |p| p.at(f));
|
||||
let same = lerp_appearance(&turning, &turning, 0.5);
|
||||
assert!((rot(&same, 0.25) - 90.0).abs() < 1e-9 && (rot(&same, 0.5).abs() - 180.0).abs() < 1e-9, "{:?}", same.stroke().map(|s| &s.rotation));
|
||||
let half = lerp_appearance(&turning, &plain, 0.5);
|
||||
assert!((rot(&half, 0.5) - 90.0).abs() < 1e-9 && (rot(&half, 1.0).abs() - 180.0).abs() < 1e-9, "{:?}", half.stroke().map(|s| &s.rotation));
|
||||
// Neither key has a recording: the steps have none either.
|
||||
let none = lerp_appearance(&plain, &plain, 0.5);
|
||||
let s = none.stroke().cloned().unwrap_or_else(|| panic!("a stroke"));
|
||||
assert!(s.pressure.is_none() && s.tilt.is_none() && s.bearing.is_none() && s.rotation.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_near_the_other_keys_step_keeps_the_step() {
|
||||
let flat = PressureProfile { points: vec![(0.0, 0.0), (0.4999995, 0.0), (1.0, 0.0)] };
|
||||
let stepped = PressureProfile { points: vec![(0.0, 0.0), (0.5, 0.0), (0.5, 1.0), (1.0, 1.0)] };
|
||||
let p = lerp_pressure(Some(&flat), Some(&stepped), 1.0).unwrap_or_default();
|
||||
assert!((p.at(0.75) - 1.0).abs() < 1e-9, "{p:?}");
|
||||
let narrow = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.4999995, 1.0, 1.0), (1.0, 1.0, 1.0)] };
|
||||
let stepped = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 0.0, 0.0), (1.0, 0.0, 0.0)] };
|
||||
let w = lerp_profile(Some(&narrow), Some(&stepped), 1.0).unwrap_or_default();
|
||||
assert!(w.at(0.75).0.abs() < 1e-9, "{w:?}");
|
||||
// The other key's step doesn't reshape a smooth profile's curve.
|
||||
let early = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.25, 1.0, 1.0), (0.25, 0.5, 0.5), (1.0, 0.5, 0.5)] };
|
||||
let w = lerp_profile(Some(&WidthProfile::lens()), Some(&early), 0.0).unwrap_or_default();
|
||||
for u in [0.1, 0.125, 0.2, 0.3, 0.4] {
|
||||
let (got, want) = (w.at(u).0, WidthProfile::lens().at(u).0);
|
||||
assert!((got - want).abs() < 5e-3, "at {u}: {got} for {want}: {w:?}");
|
||||
}
|
||||
let lens = WidthProfile::lens();
|
||||
assert_eq!(lerp_profile(Some(&lens), Some(&lens), 0.5).and_then(|p| p.preset_id()), Some("lens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_profiles_turn_with_a_key_path_started_elsewhere() {
|
||||
let keyed = |path: PathData, edit: &dyn Fn(&mut StrokeLayer)| {
|
||||
let mut ap = Appearance::default();
|
||||
ap.set_stroke(Paint::solid(Color::BLACK));
|
||||
if let Some(s) = ap.stroke_mut() {
|
||||
edit(s);
|
||||
}
|
||||
Node::path(NodeId(1), path, ap)
|
||||
};
|
||||
let stroke = |n: &Node| n.appearance.stroke().cloned().unwrap_or_else(|| panic!("a stroke"));
|
||||
let pressure = |s: &StrokeLayer, f: f64| s.pressure.as_ref().map_or(PressureProfile::MID, |p| p.at(f));
|
||||
// An open key clicked at its last anchor runs the other way, and so do its pressure, its
|
||||
// width profile (whose sides swap) and its arrowheads.
|
||||
let line = |x: f64| PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 10.0, 0.0)], false));
|
||||
let light = keyed(line(0.0), &|s| s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 0.0)] }));
|
||||
let ramp = keyed(line(100.0), &|s| {
|
||||
s.pressure = Some(PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] });
|
||||
s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 0.0, 0.0)] });
|
||||
s.start_arrow = Some(Arrowhead::Triangle);
|
||||
s.arrow_scale = (200.0, 100.0);
|
||||
});
|
||||
let blend = Lerp::new(&light, &ramp, (None, Some(1)));
|
||||
let s = stroke(&blend.at(0.25));
|
||||
assert!(pressure(&s, 0.0).abs() < 1e-9 && (pressure(&s, 1.0) - 0.25).abs() < 1e-9, "{:?}", s.pressure);
|
||||
let s = stroke(&blend.at(1.0));
|
||||
assert_eq!(s.profile.as_ref().map(|p| p.at(1.0)), Some((0.0, 1.0)), "{:?}", s.profile);
|
||||
assert_eq!((s.start_arrow, s.end_arrow, s.arrow_scale), (None, Some(Arrowhead::Triangle), (100.0, 200.0)));
|
||||
// A key whose other subpaths keep their direction keeps its one stroke as it is.
|
||||
let two = |x: f64| {
|
||||
let sub = |y: f64| SubPath::polyline(&[Point::new(x, y), Point::new(x + 10.0, y)], false);
|
||||
PathData::new(vec![sub(0.0), sub(20.0)])
|
||||
};
|
||||
let ramps = keyed(two(100.0), &|s| s.pressure = Some(PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] }));
|
||||
let s = stroke(&Lerp::new(&keyed(two(0.0), &|_| {}), &ramps, (None, Some(1))).at(1.0));
|
||||
assert!((pressure(&s, 0.0) - 1.0).abs() < 1e-9, "{:?}", s.pressure);
|
||||
// A closed key started half way round starts its recordings there too, keeping the step
|
||||
// where its own start was and the whole turn its rotation makes.
|
||||
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0));
|
||||
let plain = keyed(square(0.0), &|_| {});
|
||||
let turning = keyed(square(100.0), &|s| {
|
||||
s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] });
|
||||
s.rotation = Some(AngleProfile { points: vec![(0.0, 0.0), (1.0, 360.0)] });
|
||||
s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] });
|
||||
});
|
||||
let blend = Lerp::new(&plain, &turning, (Some(0), Some(2)));
|
||||
let s = stroke(&blend.at(1.0));
|
||||
assert!((pressure(&s, 0.25) - 0.75).abs() < 1e-9 && (pressure(&s, 0.75) - 0.25).abs() < 1e-9, "{:?}", s.pressure);
|
||||
let width = s.profile.as_ref().map_or((1.0, 1.0), |p| p.at(0.75));
|
||||
assert!((width.0 - 0.75).abs() < 1e-6 && (width.1 - 0.75).abs() < 1e-6, "the width steps where the key started: {:?}", s.profile);
|
||||
// A width curve split by the new start keeps its shape.
|
||||
let lensed = keyed(square(100.0), &|s| s.profile = Some(WidthProfile::lens()));
|
||||
let s = stroke(&Lerp::new(&plain, &lensed, (Some(0), Some(1))).at(1.0));
|
||||
for u in [0.05, 0.125, 0.2, 0.6, 0.7, 0.74, 0.76, 0.8, 0.875, 0.95] {
|
||||
let (got, want) = (s.profile.as_ref().map_or(1.0, |p| p.at(u).0), WidthProfile::lens().at((u + 0.25) % 1.0).0);
|
||||
assert!((got - want).abs() < 2e-3, "at {u}: {got} for {want}: {:?}", s.profile);
|
||||
}
|
||||
let rot = |t: f64, f: f64| stroke(&blend.at(t)).rotation.as_ref().map_or(0.0, |p| p.unwrapped_at(f));
|
||||
assert!((vectorcraft_geom::normalize_deg(rot(1.0, 0.25)) + 90.0).abs() < 1e-6, "{:?}", s.rotation);
|
||||
assert!((rot(0.5, 0.499) - rot(0.5, 0.501)).abs() < 1.0, "no jump where the key started: {:?}", stroke(&blend.at(0.5)).rotation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_dash_blends_to_solid_with_closed_gaps() {
|
||||
let d = lerp_dash(Some(&Dash { pattern: vec![2.0, 3.0, 4.0], ..Default::default() }), None, 1.0).unwrap();
|
||||
|
||||
@@ -119,6 +119,12 @@ impl BlendSpec {
|
||||
pub fn start(&self, i: usize) -> Option<usize> {
|
||||
self.starts.get(i).copied().flatten().map(|a| a as usize)
|
||||
}
|
||||
|
||||
/// Whether [`Self::ease`] moves the steps: the blend has a spine ([`Self::no_spine`] is off)
|
||||
/// and a replaced spine or Align to Path, the cases where the steps are placed along it.
|
||||
pub fn eases_along_spine(&self) -> bool {
|
||||
!self.no_spine && (self.spine.is_some() || self.orientation == BlendOrientation::AlignToPath)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Blend panel's Create Spine setting: whether a new blend gets a spine.
|
||||
|
||||
@@ -237,6 +237,10 @@ pub struct ParaStyle {
|
||||
/// Kinsoku Shori Type (Paragraph panel menu): how a line keeps those characters off its start
|
||||
/// and end, by pushing them in onto the line or out to the next one. No effect without kinsoku.
|
||||
pub kinsoku_type: KinsokuShoriType,
|
||||
/// Bunri-Kinshi (Paragraph panel menu): a line never breaks inside an inseparable pair (two em
|
||||
/// dashes, two ellipses, two two-dot leaders, a vertical kana repeat mark's two halves). Kept
|
||||
/// with kinsoku None, where it has no effect. Off by default.
|
||||
pub bunri_kinshi: bool,
|
||||
/// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and
|
||||
/// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses,
|
||||
/// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari.
|
||||
@@ -339,6 +343,8 @@ struct ParaStyleFile {
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
kinsoku_type: KinsokuShoriType,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
bunri_kinshi: bool,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
roman_hanging: bool,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
kashida: Kashida,
|
||||
@@ -375,6 +381,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
kinsoku_type,
|
||||
bunri_kinshi,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
korean_line_break_anywhere,
|
||||
@@ -401,6 +408,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
kinsoku_type,
|
||||
bunri_kinshi,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
korean_line_break_anywhere,
|
||||
@@ -431,6 +439,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
kinsoku_type,
|
||||
bunri_kinshi,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
korean_line_break_anywhere,
|
||||
@@ -456,6 +465,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
kinsoku_type,
|
||||
bunri_kinshi,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
korean_line_break_anywhere,
|
||||
|
||||
@@ -29,7 +29,7 @@ use crate::group::{has_own_paint, paints};
|
||||
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
|
||||
use crate::{
|
||||
GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
|
||||
has_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
|
||||
has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
|
||||
};
|
||||
|
||||
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
|
||||
@@ -41,8 +41,8 @@ fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
|
||||
|
||||
fn clear_item_effects(item: &mut AppearanceItem) {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => f.effects.retain(|e| !is_geometry(&e.id)),
|
||||
AppearanceItem::Stroke(s) => s.effects.retain(|e| !is_geometry(&e.id)),
|
||||
AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects),
|
||||
AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +337,7 @@ fn moved_type_group(n: &Node) -> Option<Node> {
|
||||
g.knockout = Knockout::Off;
|
||||
g.knockout_shape = n.knockout_shape;
|
||||
g.extras_mut().mask = n.extras().mask.clone();
|
||||
g.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
g.appearance.effects = crate::raster::without_geometry(&n.appearance.effects);
|
||||
Some(g)
|
||||
}
|
||||
|
||||
@@ -397,7 +397,8 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
|
||||
return None;
|
||||
}
|
||||
reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d));
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id));
|
||||
m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects);
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id));
|
||||
m.appearance.items.clear();
|
||||
m.appearance.contents_index = None;
|
||||
Some(m)
|
||||
@@ -439,7 +440,7 @@ fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
m.appearance.effects.retain(|e| !is_geometry(&e.id));
|
||||
m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects);
|
||||
Some((g, rule, ctx, m))
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,8 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
|
||||
"brushStrokes.accentedEdges" => always(&["edgeWidth"]),
|
||||
"artistic.paletteKnife" => always(&["strokeSize"]),
|
||||
"artistic.coloredPencil" => always(&["pencilWidth"]),
|
||||
"artistic.paintDaubs" => always(&["brushSize"]),
|
||||
"brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]),
|
||||
"brushStrokes.spatter" => always(&["sprayRadius"]),
|
||||
"brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]),
|
||||
@@ -258,14 +260,14 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"distort.roughen",
|
||||
"Roughen…",
|
||||
DT,
|
||||
"{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}",
|
||||
"{size: 0..100 (5; 0 keeps the outline and adds its points along it), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}",
|
||||
json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}),
|
||||
),
|
||||
g(
|
||||
"distort.transform",
|
||||
"Transform…",
|
||||
DT,
|
||||
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}",
|
||||
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power; on a fill or stroke, the effects listed after this effect in that fill's or stroke's own list keep their distances and apply to the transformed art, and with objects off so do the effects before it)}",
|
||||
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}),
|
||||
),
|
||||
g(
|
||||
@@ -387,7 +389,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"artistic.paintDaubs",
|
||||
"Paint Daubs…",
|
||||
ARTISTIC,
|
||||
"{brushSize: 1..50 (10; dab centres farther apart), sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
"{brushSize: 1..50 (10; spacing of the dab centers in document units),sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"brushSize": 10.0, "sharpness": 25.0, "brushType": "simple"}),
|
||||
),
|
||||
r(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::kurbo::ParamCurveArclen;
|
||||
use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
|
||||
use vectorcraft_pathops::addons::pathtools::insert_at;
|
||||
|
||||
use crate::util::{catmull_rom, fit_piece, flag, map_nonlinear_within, mean_size, noise, normal_at, num, pieces, seed, seg_cubic, smooth_points};
|
||||
|
||||
@@ -79,7 +80,8 @@ const MAX_ROUGHEN_POINTS: usize = 2000;
|
||||
|
||||
/// Roughen: resample at `detail` points per inch and jitter each point by up to `size`. Where
|
||||
/// that would make more than `max` anchors, the points are spaced further apart until it doesn't
|
||||
/// (stacked Roughens multiply their points, #3578).
|
||||
/// (stacked Roughens multiply their points, #3578). At size 0 no point moves: each segment is
|
||||
/// split into its pieces, evenly along its length, and the outline stays as it is.
|
||||
pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
let size = num(p, "size", 5.0).clamp(0.0, 1e4);
|
||||
let amp = if flag(p, "relative", true) { size / 100.0 * mean_size(b) } else { size };
|
||||
@@ -89,6 +91,23 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
let lens: Vec<f64> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i).arclen(0.1))).collect();
|
||||
let spacing = fit_piece(&lens, spacing, MAX_ROUGHEN_POINTS, max.saturating_sub(path.anchor_count()));
|
||||
let mut lens = lens.iter();
|
||||
if amp == 0.0 {
|
||||
// Each segment's pieces, cut at equal lengths as `pathtools::add_points` cuts them.
|
||||
let mut subs = Vec::with_capacity(path.subpaths.len());
|
||||
for sp in &path.subpaths {
|
||||
let mut places = vec![];
|
||||
for (i, &coarse) in (0..sp.segment_count()).zip(&mut lens) {
|
||||
let k = pieces(coarse, spacing, MAX_ROUGHEN_POINTS);
|
||||
let c = sp.segment(i);
|
||||
let len = c.arclen(ROUGHEN_ARCLEN_ACCURACY);
|
||||
if len > 1e-9 && len.is_finite() {
|
||||
places.extend((1..k).map(|j| (i, c.inv_arclen(len * j as f64 / k as f64, ROUGHEN_ARCLEN_ACCURACY))));
|
||||
}
|
||||
}
|
||||
subs.push(insert_at(sp, &places).0);
|
||||
}
|
||||
return PathData::new(subs);
|
||||
}
|
||||
let smooth = smooth_points(p);
|
||||
let seed = seed(p);
|
||||
let mut subs = vec![];
|
||||
@@ -118,6 +137,8 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
PathData::new(subs)
|
||||
}
|
||||
|
||||
const ROUGHEN_ARCLEN_ACCURACY: f64 = 1e-4;
|
||||
|
||||
/// The move and the number of copies of a Transform effect with params `p` that only moves what
|
||||
/// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects,
|
||||
/// or moves by a random share, or moves pattern paints along (Transform Patterns), or scales
|
||||
|
||||
@@ -265,7 +265,7 @@ fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node {
|
||||
fn without_geometry_effects(n: &mut Node) {
|
||||
if n.appearance.effects.iter().any(|e| crate::is_geometry(&e.id)) {
|
||||
let mut ap = (*n.appearance).clone();
|
||||
ap.effects.retain(|e| !crate::is_geometry(&e.id));
|
||||
ap.effects = crate::raster::without_geometry(&ap.effects);
|
||||
n.appearance = ap.into();
|
||||
}
|
||||
}
|
||||
@@ -794,7 +794,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
}
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id));
|
||||
m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects);
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id));
|
||||
if paint {
|
||||
let shapes = member_shapes(doc, &m);
|
||||
let bounds = m.geometric_bounds();
|
||||
|
||||
@@ -78,7 +78,7 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par
|
||||
pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner};
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects};
|
||||
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects, without_geometry};
|
||||
pub use reshape::{
|
||||
SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art,
|
||||
has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Raster (painted) effects: descriptions for the renderer.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::color::{BlendMode, Color};
|
||||
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
|
||||
use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings};
|
||||
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use crate::merged_params;
|
||||
use crate::pixel::{self, PixelFx, PixelSpace};
|
||||
@@ -384,9 +386,10 @@ fn opacity(p: &Value) -> f32 {
|
||||
(num(p, "opacity", 75.0) / 100.0).clamp(0.0, 1.0) as f32
|
||||
}
|
||||
|
||||
/// The visible raster effects of `effects`, in stack order.
|
||||
/// The visible raster effects of `effects`, in stack order, each following the Transform effects
|
||||
/// listed after it ([`followed`]).
|
||||
pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
effects
|
||||
followed(effects)
|
||||
.iter()
|
||||
.filter(|e| e.visible)
|
||||
.filter_map(|e| {
|
||||
@@ -426,6 +429,116 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `effects` with each raster effect following the visible Transform effects listed after it
|
||||
/// ([`follow_transform`]): effects apply in the order they are listed, so a Transform effect
|
||||
/// transforms what the raster effects before it paint, whatever its Scale Strokes & Effects says.
|
||||
/// A hidden raster effect follows too, so it draws the same when it is shown after the Transform
|
||||
/// effect has been expanded. A raster effect that a Transform effect making copies or moving at
|
||||
/// random follows stays as it is, as does one after the last Transform effect. Borrowed when
|
||||
/// nothing changes.
|
||||
pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> {
|
||||
if !effects.iter().any(|e| e.visible && e.id == "distort.transform") {
|
||||
return Cow::Borrowed(effects);
|
||||
}
|
||||
let mut out: Option<Vec<Effect>> = None;
|
||||
// The linear part of the Transform effects after the effect at hand, the last one outermost;
|
||||
// `None` once one of them makes copies or moves at random.
|
||||
let mut after = Some(Affine::IDENTITY);
|
||||
for (i, e) in effects.iter().enumerate().rev() {
|
||||
if e.id == "distort.transform" {
|
||||
after = after.and_then(|a| transform_step(e).map(|s| a * s));
|
||||
continue;
|
||||
}
|
||||
let Some(a) = after.filter(|a| *a != Affine::IDENTITY && follows(&e.id)) else { continue };
|
||||
let list = out.get_or_insert_with(|| effects.to_vec());
|
||||
if let Some(e) = list.get_mut(i) {
|
||||
follow_transform(e, a);
|
||||
}
|
||||
}
|
||||
out.map_or(Cow::Borrowed(effects), Cow::Owned)
|
||||
}
|
||||
|
||||
/// `effects` without their geometry effects, each raster effect following the Transform effects
|
||||
/// it loses ([`followed`]): what an object, or a fill or stroke, keeps once its geometry effects
|
||||
/// are applied to its art.
|
||||
pub fn without_geometry(effects: &[Effect]) -> Vec<Effect> {
|
||||
followed(effects).iter().filter(|e| !crate::is_geometry(&e.id)).cloned().collect()
|
||||
}
|
||||
|
||||
/// Make the raster effects of `effects`, all listed before Transform effect `t`, follow `t` as
|
||||
/// [`followed`] makes them follow it, for art that applies its effects one at a time. `false`
|
||||
/// when `t` leaves them as they are.
|
||||
pub(crate) fn follow_one(effects: &mut [Effect], t: &Effect) -> bool {
|
||||
let Some(a) = transform_step(t).filter(|a| *a != Affine::IDENTITY) else { return false };
|
||||
let mut changed = false;
|
||||
for e in effects.iter_mut().filter(|e| follows(&e.id)) {
|
||||
follow_transform(e, a);
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// How many of the effects of `effects` that [`without_geometry`] keeps no longer follow the
|
||||
/// Transform effects listed after `effects` ([`followed`]): those before the last visible Transform
|
||||
/// effect among them that makes copies or moves at random.
|
||||
pub(crate) fn held_back(effects: &[Effect]) -> usize {
|
||||
let Some(last) = effects.iter().rposition(|e| transform_step(e).is_none()) else { return 0 };
|
||||
effects.get(..last).unwrap_or_default().iter().filter(|e| !crate::is_geometry(&e.id)).count()
|
||||
}
|
||||
|
||||
/// Does `e` stop the raster effects listed before it from following the Transform effects after
|
||||
/// it ([`followed`]): is it a visible Transform effect that makes copies or moves at random?
|
||||
pub(crate) fn holds_back(e: &Effect) -> bool {
|
||||
transform_step(e).is_none()
|
||||
}
|
||||
|
||||
/// What `e` does to the raster effects listed before it ([`followed`]): the linear part of its map
|
||||
/// when it is a visible Transform effect that moves the art once, the identity when it is no
|
||||
/// Transform effect, is hidden or has Transform Objects off (the art stays where it is), and `None`
|
||||
/// when it makes copies or moves at random.
|
||||
fn transform_step(e: &Effect) -> Option<Affine> {
|
||||
if e.id != "distort.transform" || !e.visible {
|
||||
return Some(Affine::IDENTITY);
|
||||
}
|
||||
let p = merged_params(&e.id, &e.params);
|
||||
if !flag(&p, "objects", true) {
|
||||
return Some(Affine::IDENTITY);
|
||||
}
|
||||
let (map, copies) = crate::distort::transform_map(Rect::ZERO, &p, 0);
|
||||
(copies == 0 && !flag(&p, "random", false)).then(|| map.with_translation(Vec2::ZERO))
|
||||
}
|
||||
|
||||
/// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG
|
||||
/// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room
|
||||
/// around the art.
|
||||
fn follows(id: &str) -> bool {
|
||||
crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE
|
||||
}
|
||||
|
||||
/// Make raster effect `e` follow `a`, the linear part of a Transform effect listed after it, as
|
||||
/// that effect transforms what `e` paints: its distances and raster sizes scale by `a`'s mean
|
||||
/// scale ([`crate::scale_effect`]), and a Drop Shadow's offset turns, reflects and scales with
|
||||
/// `a`. Effects that [`follows`] leaves out stay as they are.
|
||||
fn follow_transform(e: &mut Effect, a: Affine) {
|
||||
if !follows(&e.id) {
|
||||
return;
|
||||
}
|
||||
let offset = (e.id == "stylize.dropShadow").then(|| {
|
||||
let p = merged_params(&e.id, &e.params);
|
||||
a * Point::new(num(&p, "x", 7.0), num(&p, "y", 7.0))
|
||||
});
|
||||
if let Some(k) = vectorcraft_doc::Scaling::factor(a) {
|
||||
crate::scale_effect(e, k);
|
||||
}
|
||||
if let Some(o) = offset.filter(|o| o.x.is_finite() && o.y.is_finite()) {
|
||||
if !e.params.is_object() {
|
||||
e.params = Value::Object(Map::new());
|
||||
}
|
||||
e.params["x"] = json!(o.x);
|
||||
e.params["y"] = json!(o.y);
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the visible raster effects of `effects` paint beyond `bounds`, the (effected)
|
||||
/// geometry's, in document units. Geometry effects are accounted for by evaluating them.
|
||||
pub fn outset(effects: &[Effect], bounds: Rect) -> f64 {
|
||||
|
||||
+123
-28
@@ -605,7 +605,7 @@ fn as_art_opts(
|
||||
out.knockout = vectorcraft_doc::Knockout::Off;
|
||||
out.knockout_shape = n.knockout_shape;
|
||||
out.extras_mut().mask = n.extras().mask.clone();
|
||||
out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
out.appearance.effects = crate::raster::without_geometry(&n.appearance.effects);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -945,7 +945,7 @@ impl Step<'_> {
|
||||
// The copies of a path replace it by a group of them, the path included.
|
||||
let copied = |leaf: &Node| leaf.children().map_or(0, Vec::len);
|
||||
if options.reach_appearance() {
|
||||
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room);
|
||||
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, None);
|
||||
*used = used.minus(before).plus(made);
|
||||
if moved {
|
||||
// With Transform Objects off the path stays, and its mask takes the
|
||||
@@ -961,7 +961,7 @@ impl Step<'_> {
|
||||
}
|
||||
if separate {
|
||||
let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() };
|
||||
let made = copy_leaf(leaf, &path, m, copies, scaling, room);
|
||||
let made = copy_leaf(leaf, &path, m, copies, scaling, room, None);
|
||||
*used = used.minus(before).plus(made);
|
||||
moved_ids.insert(leaf.id);
|
||||
short |= counts(leaf.id) && copied(leaf) <= copies;
|
||||
@@ -1246,8 +1246,9 @@ fn copy_scaling(options: crate::distort::TransformOptions) -> vectorcraft_doc::S
|
||||
/// and the distances of the effects on its fills and strokes scale by `m`'s mean scale, and its
|
||||
/// pattern paints transform by `m`. With copies, the original keeps them and copy `k` takes them
|
||||
/// `k` times, each copy an object of its own ([`copy_leaf`], within `budget`). With Transform
|
||||
/// Objects off the geometry stays and the options apply once. Returns what the leaf costs after,
|
||||
/// and `false` when the path kept its shape because the moved path would pass `budget`.
|
||||
/// Objects off the geometry stays and the options apply once. The effects `kept` names keep their
|
||||
/// values on the leaf and on each copy ([`keep_listed`]). Returns what the leaf costs after, and
|
||||
/// `false` when the path kept its shape because the moved path would pass `budget`.
|
||||
fn transform_leaf(
|
||||
leaf: &mut Node,
|
||||
path: &PathData,
|
||||
@@ -1255,6 +1256,7 @@ fn transform_leaf(
|
||||
copies: usize,
|
||||
options: crate::distort::TransformOptions,
|
||||
budget: Budget,
|
||||
kept: Kept,
|
||||
) -> (Budget, bool) {
|
||||
let scaling = copy_scaling(options);
|
||||
if !options.objects {
|
||||
@@ -1267,6 +1269,7 @@ fn transform_leaf(
|
||||
if options.patterns {
|
||||
vectorcraft_doc::pattern::transform_pattern_paints(leaf, m);
|
||||
}
|
||||
keep_listed(leaf, kept);
|
||||
return (Budget::of(path), true);
|
||||
}
|
||||
if copies == 0 {
|
||||
@@ -1276,9 +1279,10 @@ fn transform_leaf(
|
||||
return (Budget::of(path), false);
|
||||
}
|
||||
leaf.transform(m, scaling);
|
||||
keep_listed(leaf, kept);
|
||||
return (Budget::of(&moved), true);
|
||||
}
|
||||
(copy_leaf(leaf, path, m, copies, scaling, budget), true)
|
||||
(copy_leaf(leaf, path, m, copies, scaling, budget, kept), true)
|
||||
}
|
||||
|
||||
/// Does a Transform effect's copies of `art` copy it whole ([`copy_art`])? They do when `art` is a
|
||||
@@ -1424,9 +1428,10 @@ fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::
|
||||
/// moves with it) rather than spanning every copy, and its strokes and patterns can differ from the
|
||||
/// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass
|
||||
/// `budget` with what each copy's own geometry effects make ([`effects_cost`]), and before one's
|
||||
/// strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the copies cost, the original
|
||||
/// included.
|
||||
fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget {
|
||||
/// strokes would scale past [`MAX_STROKE_SCALE`]. The effects `kept` names keep their values on
|
||||
/// each copy, before its cost is counted ([`keep_listed`]). Returns what the copies cost, the
|
||||
/// original included.
|
||||
fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget, kept: Kept) -> Budget {
|
||||
let mut made = Budget::of(path).plus(effects_cost(leaf, path));
|
||||
let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())];
|
||||
let mut xf = Affine::IDENTITY;
|
||||
@@ -1439,6 +1444,7 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling
|
||||
}
|
||||
let mut copy = leaf.clone();
|
||||
copy.transform(xf, scaling);
|
||||
keep_listed(&mut copy, kept);
|
||||
// Each copy's own geometry effects make a different amount, as the copy and its effects scale.
|
||||
let mut cost = Budget::of(&moved).plus(effects_cost(©, &moved));
|
||||
// Each copy carries the art of its linked masks, moved with it.
|
||||
@@ -1585,9 +1591,12 @@ fn item_makes_piece(item: &AppearanceItem) -> bool {
|
||||
/// effects. In paint order, each such fill or stroke is a path of its own: the leaf's geometry after
|
||||
/// the geometry effects of that fill or stroke before its first such Transform effect, transformed
|
||||
/// as [`transform_leaf`] transforms an object with that effect's map, copies and options, so the
|
||||
/// stroke's weight and dashes, the distances of its remaining effects and its pattern follow the
|
||||
/// map. The reference box is that geometry's bounds and the seed is the leaf's id, as the renderer
|
||||
/// draws a fill's or stroke's own effects. The geometry effects after the Transform effect then
|
||||
/// stroke's weight and dashes, the distances of the effects before the Transform effect and its
|
||||
/// pattern follow the map, and the raster effects before it that follow it take their followed
|
||||
/// values ([`follow_piece`]). The effects after it keep their values and apply to what it made
|
||||
/// ([`keep_listed`]), and with Transform Objects off so do the effects before it. The reference box
|
||||
/// is that geometry's bounds and the seed is the leaf's id, as the renderer draws a fill's or
|
||||
/// stroke's own effects. The geometry effects after the Transform effect then
|
||||
/// apply to the transformed path and its copies as one piece ([`transform_piece`]), and its other
|
||||
/// effects stay on each of them. The other fills and strokes stay together on paths of the leaf's
|
||||
/// geometry between them, with their own effects. `max` is the budget of the whole art and `used`
|
||||
@@ -1660,9 +1669,16 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
let params = merged_params(&fx[at].id, &fx[at].params);
|
||||
// Where each effect the piece keeps sits among the item's effects.
|
||||
let kept: Vec<usize> = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect();
|
||||
// A Transform effect after this one that makes copies or moves at random holds the raster
|
||||
// effects before this one back from every Transform effect ([`crate::raster::followed`]).
|
||||
let held_later = fx.get(at + 1..).unwrap_or_default().iter().any(crate::raster::holds_back);
|
||||
// The effects before the Transform effect that it keeps, each raster effect following the
|
||||
// geometry effects before it ([`crate::raster::without_geometry`]) unless held back.
|
||||
let kept_before: Vec<Effect> =
|
||||
if held_later { before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() } else { crate::without_geometry(before) };
|
||||
if let Some(plugin) = painted_plugin(&fx[at]) {
|
||||
let mut it = item.clone();
|
||||
let rest: Vec<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
|
||||
let rest: Vec<Effect> = kept_before.iter().chain(&fx[at + 1..]).cloned().collect();
|
||||
*it.effects_mut() = rest;
|
||||
let mut one = Appearance { items: vec![it.clone()], ..Default::default() };
|
||||
if carry.moves(&one) {
|
||||
@@ -1684,7 +1700,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
group.push(Arc::new(p));
|
||||
}
|
||||
let mut p = Node::group(leaf.id, group);
|
||||
transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc);
|
||||
transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), limit, used, doc);
|
||||
pieces.push(Arc::new(p));
|
||||
continue;
|
||||
}
|
||||
@@ -1692,16 +1708,22 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
let options = crate::distort::TransformOptions::of(¶ms);
|
||||
let map = crate::distort::transform_affine(tb, ¶ms, ctx.seed);
|
||||
let mut it = item.clone();
|
||||
let rest: Vec<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
|
||||
*it.effects_mut() = rest;
|
||||
let rest: Vec<Effect> = kept_before.iter().chain(&fx[at + 1..]).cloned().collect();
|
||||
*it.effects_mut() = rest.clone();
|
||||
let mut p = piece(vec![it]);
|
||||
if carry.moves(&p.appearance) {
|
||||
carry.apply(&mut p.appearance);
|
||||
}
|
||||
set_geometry(&mut p, g.clone(), rule);
|
||||
let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room);
|
||||
let after = kept_before.len();
|
||||
let keep = |k: usize| k >= after || !options.objects;
|
||||
let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room, Some((&rest, &keep)));
|
||||
*used = used.plus(made);
|
||||
transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc);
|
||||
let from = crate::raster::held_back(before);
|
||||
if moved && !held_later {
|
||||
follow_piece(&mut p, kept_before.get(from..).unwrap_or_default(), from, &fx[at]);
|
||||
}
|
||||
transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), limit, used, doc);
|
||||
pieces.push(Arc::new(p));
|
||||
}
|
||||
if !plain.is_empty() {
|
||||
@@ -1718,27 +1740,45 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
/// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece:
|
||||
/// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's
|
||||
/// own effects reshape all its paths ([`reshape_leaves_carrying`]). Each path applies its own copy of
|
||||
/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with
|
||||
/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does.
|
||||
/// the effect, at the effect's own distances. A Transform effect with Transform Patterns or Scale
|
||||
/// Strokes & Effects on transforms each path as [`transform_leaf`] does, and leaves the values of
|
||||
/// the effects after it, and with Transform Objects off those before it, as they are
|
||||
/// ([`keep_listed`]).
|
||||
/// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path
|
||||
/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and
|
||||
/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art
|
||||
/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. `(seed, source,
|
||||
/// kept)`: the object's seed, the index in the object's appearance of the fill or stroke the
|
||||
/// piece was made from, and the index among that item's effects of each effect the piece kept,
|
||||
/// which a deferred live-effect plug-in is listed by.
|
||||
fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), max: Budget, used: &mut Budget, doc: Option<&Document>) {
|
||||
/// for each piece it returns ([`paint_piece`]). The raster effects before a Transform effect
|
||||
/// follow it ([`follow_piece`]), except the first `held` effects, which a Transform effect before
|
||||
/// the piece's effects that makes copies or moves at random holds back, and the effects before
|
||||
/// such a Transform effect among the piece's effects. The geometry effects then
|
||||
/// leave the fills and strokes; their other effects stay. The paths keep to `max` together with
|
||||
/// the rest of the art (`used`), and the swatches and patterns painted pieces name must be in
|
||||
/// `doc`. `(seed, source, kept)`: the object's seed, the index in the object's appearance of the
|
||||
/// fill or stroke the piece was made from, and the index among that item's effects of each effect
|
||||
/// the piece kept, which a deferred live-effect plug-in is listed by.
|
||||
fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), held: usize, max: Budget, used: &mut Budget, doc: Option<&Document>) {
|
||||
let mut count = 0;
|
||||
for_each_leaf(p, &mut |l| {
|
||||
if let Some(item) = l.appearance.items.first() {
|
||||
count = count.max(item.effects().len());
|
||||
}
|
||||
});
|
||||
// The effects before a Transform effect among the piece's effects that makes copies or moves at
|
||||
// random are held back from the Transform effects before it too.
|
||||
let mut held = held;
|
||||
for_each_leaf(p, &mut |l| {
|
||||
if let Some(last) = l.appearance.items.first().and_then(|item| item.effects().iter().rposition(crate::raster::holds_back)) {
|
||||
held = held.max(last);
|
||||
}
|
||||
});
|
||||
for j in 0..count {
|
||||
let Some(bounds) = p.geometric_bounds() else { break };
|
||||
let from = held.min(j);
|
||||
let mut holds = false;
|
||||
for_each_leaf(p, &mut |leaf| {
|
||||
let Some(item) = leaf.appearance.items.first().cloned() else { return };
|
||||
let Some(e) = item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned() else { return };
|
||||
holds |= crate::raster::holds_back(&e);
|
||||
// The effects before this one as they were before it moved the path.
|
||||
let was = item.effects().get(from..j).unwrap_or_default();
|
||||
let Some((path, rule)) = leaf_geometry(leaf) else { return };
|
||||
// What the other paths leave of the budget.
|
||||
let before = Budget::of(&path);
|
||||
@@ -1755,8 +1795,12 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m
|
||||
let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed);
|
||||
let options = crate::distort::TransformOptions::of(¶ms);
|
||||
let map = crate::distort::transform_affine(bounds, ¶ms, seed);
|
||||
let (made, _) = transform_leaf(leaf, &path, map, copies, options, room);
|
||||
let keep = |k: usize| k > j || (k < j && !options.objects);
|
||||
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, Some((item.effects(), &keep)));
|
||||
*used = used.minus(before).plus(made);
|
||||
if moved {
|
||||
follow_piece(leaf, was, from, &e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let appearance = leaf.appearance.clone();
|
||||
@@ -1773,7 +1817,11 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m
|
||||
carry.apply(&mut leaf.appearance);
|
||||
}
|
||||
set_geometry(leaf, out, rule);
|
||||
follow_piece(leaf, was, from, &e);
|
||||
});
|
||||
if holds {
|
||||
held = held.max(j);
|
||||
}
|
||||
}
|
||||
for_each_leaf(p, &mut |leaf| {
|
||||
for item in &mut leaf.appearance.items {
|
||||
@@ -1782,6 +1830,53 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m
|
||||
});
|
||||
}
|
||||
|
||||
/// The effects a Transform effect in a fill's or stroke's own list leaves as they were
|
||||
/// ([`keep_listed`]): the list as it was before the Transform effect was applied, and which of its
|
||||
/// indices keep their values. `None` for an object's own Transform effect.
|
||||
type Kept<'a> = Option<(&'a [Effect], &'a dyn Fn(usize) -> bool)>;
|
||||
|
||||
/// Put back on the fills and strokes of each path under `p` the effect `kept`'s list holds at each
|
||||
/// index it takes, in place of what a Transform effect in their own list scaled or moved with its
|
||||
/// options ([`transform_leaf`]). The effects after the Transform effect apply to the art it made,
|
||||
/// and with Transform Objects off the effects before it keep their values too. A piece's fills and
|
||||
/// strokes share one list: a live-effect plug-in that paints what it returns gives each paint a copy
|
||||
/// of the fill or stroke ([`vectorcraft_plugins::painted::items`]).
|
||||
fn keep_listed(p: &mut Node, kept: Kept) {
|
||||
let Some((was, keep)) = kept else { return };
|
||||
for_each_leaf(p, &mut |leaf| {
|
||||
for item in &mut leaf.appearance.items {
|
||||
for (k, (e, old)) in item.effects_mut().iter_mut().zip(was).enumerate() {
|
||||
if keep(k) {
|
||||
e.clone_from(old);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Make the raster effects of each of `leaf`'s fills and strokes from index `from` on follow
|
||||
/// Transform effect `t`, listed after them, once `t` has moved the leaf
|
||||
/// ([`crate::raster::follow_one`]). The fills and strokes are copies of one fill or stroke (those a
|
||||
/// painted plug-in returns, [`vectorcraft_plugins::painted::items`]), with the same effects. `was`
|
||||
/// holds those effects of the first one as they were before `t` was applied: each one that
|
||||
/// follows takes the value [`crate::raster::followed`] gives it, in place of what Scale Strokes &
|
||||
/// Effects made of it, and the others keep theirs.
|
||||
fn follow_piece(leaf: &mut Node, was: &[Effect], from: usize, t: &Effect) {
|
||||
let mut follow = was.to_vec();
|
||||
if !crate::raster::follow_one(&mut follow, t) {
|
||||
return;
|
||||
}
|
||||
for item in &mut leaf.appearance.items {
|
||||
for (k, (new, old)) in follow.iter().zip(was).enumerate() {
|
||||
if new != old
|
||||
&& let Some(e) = item.effects_mut().get_mut(from + k)
|
||||
{
|
||||
*e = new.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in
|
||||
/// order. A group inside one of them is part of it.
|
||||
fn type_outline_bounds(n: &Node, out: &mut Vec<Option<Rect>>) {
|
||||
|
||||
@@ -8,7 +8,9 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, L
|
||||
use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2};
|
||||
use vectorcraft_three_d::{Edge, Revolve};
|
||||
|
||||
use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve_for, solid_paints, union_bounds};
|
||||
use crate::solids::{
|
||||
Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds,
|
||||
};
|
||||
use crate::util::{flag, num, text};
|
||||
use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params};
|
||||
|
||||
@@ -558,17 +560,11 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
|
||||
}
|
||||
children.push(Arc::new(surface));
|
||||
// Later geometry acts on the projected art as a whole through container evaluation.
|
||||
remaining = n
|
||||
.appearance
|
||||
.effects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, e)| !is_revolve(&e.id) && (!is_geometry(&e.id) || *i > index))
|
||||
.map(|(_, e)| e.clone())
|
||||
.collect();
|
||||
remaining = past_effect(&n.appearance.effects, index);
|
||||
remaining.retain(|e| !is_revolve(&e.id));
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
remaining.retain(|e| !is_geometry(&e.id));
|
||||
remaining = crate::without_geometry(&remaining);
|
||||
// Every item's shadow goes behind every item's art.
|
||||
let mut shadows = vec![];
|
||||
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
|
||||
@@ -835,8 +831,7 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> {
|
||||
// One shadow for every member's surface, behind them all.
|
||||
let mut children = children;
|
||||
children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options).ok()?);
|
||||
let remaining =
|
||||
n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect();
|
||||
let remaining = past_effect(&n.appearance.effects, index);
|
||||
let mut art = n.clone();
|
||||
art.kind = NodeKind::Group { children, clip: false };
|
||||
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
|
||||
|
||||
@@ -978,17 +978,10 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
let (shadow, surfaces) = art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?;
|
||||
children.extend(shadow.into_iter().chain(surfaces));
|
||||
// Later geometry effects act on the projected art as a whole.
|
||||
remaining = n
|
||||
.appearance
|
||||
.effects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index))
|
||||
.map(|(_, e)| e.clone())
|
||||
.collect();
|
||||
remaining = past_effect(&n.appearance.effects, index);
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
remaining = crate::without_geometry(&n.appearance.effects);
|
||||
// Every item's shadow goes behind every item's art.
|
||||
let mut shadows = vec![];
|
||||
for item in &paints {
|
||||
@@ -1024,14 +1017,28 @@ fn group_solid_art(n: &Node) -> Option<Node> {
|
||||
reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes);
|
||||
let (shadow, surfaces) = art_for(n, fx, &shapes)?;
|
||||
let children = shadow.into_iter().chain(surfaces).collect();
|
||||
let remaining =
|
||||
n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect();
|
||||
let remaining = past_effect(&n.appearance.effects, index);
|
||||
let mut art = n.clone();
|
||||
art.kind = NodeKind::Group { children, clip: false };
|
||||
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// `effects` without the 3D effect at `index` and the geometry effects before it, which shape the
|
||||
/// art it projects. Each raster effect before it follows the Transform effects among those
|
||||
/// ([`crate::without_geometry`]), unless a Transform effect after it makes copies or moves at
|
||||
/// random ([`crate::raster::holds_back`]); the effects after it stay as they are.
|
||||
pub(crate) fn past_effect(effects: &[Effect], index: usize) -> Vec<Effect> {
|
||||
let (before, after) = effects.split_at(index.min(effects.len()));
|
||||
let after = after.get(1..).unwrap_or_default();
|
||||
let kept: Vec<Effect> = if after.iter().any(crate::raster::holds_back) {
|
||||
before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect()
|
||||
} else {
|
||||
crate::without_geometry(before)
|
||||
};
|
||||
kept.into_iter().chain(after.iter().cloned()).collect()
|
||||
}
|
||||
|
||||
/// One solid of a 3D object as a mesh, with the color it is painted.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolidMesh {
|
||||
|
||||
@@ -134,6 +134,10 @@ impl Plan {
|
||||
|
||||
fn fitted(mut segs: Vec<PathSeg>, mut lens: Vec<f64>, closed: bool, period: f64) -> Self {
|
||||
let n = segs.len();
|
||||
// A subpath with no segments (a lone moveto) has no runs to fit.
|
||||
if n == 0 {
|
||||
return Self::exact(segs, lens, closed, period);
|
||||
}
|
||||
// Corners: where segments with length meet at a kink (zero-length ones have no direction).
|
||||
let live: Vec<usize> = (0..n).filter(|&i| lens[i] > EPS).collect();
|
||||
let mut corners: Vec<usize> = live.windows(2).filter(|w| kink(&segs[w[0]], &segs[w[1]])).map(|w| w[1]).collect();
|
||||
|
||||
@@ -37,6 +37,20 @@ fn near(a: f64, b: f64, tol: f64) -> bool {
|
||||
|
||||
// ---------------------------------------------------------------- dashes fitted to corners
|
||||
|
||||
/// A subpath with no segments (a lone moveto, as an HP-GL wedge of almost no radius gives) is
|
||||
/// dashed fitted to corners without a panic, and the other subpaths keep their dashes.
|
||||
#[test]
|
||||
fn fitted_dashes_take_a_subpath_with_no_segments() {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((5.0, 5.0));
|
||||
b.extend(line(100.0));
|
||||
let d = dash(&b, &fitted(&[10.0, 5.0], 0.0)).expect("dashed");
|
||||
assert!(moves(&d.path) > 1 && length(&d.path) > 0.0, "{:?}", d.path);
|
||||
let mut lone = BezPath::new();
|
||||
lone.move_to((0.0, 0.0));
|
||||
assert!(dash(&lone, &fitted(&[10.0, 5.0], 0.0)).is_some_and(|d| d.path.elements().is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_corner_of_a_rectangle_falls_mid_dash() {
|
||||
// 100 × 50 with 12/6: the long sides hold 6 periods (scaled by 100/108), the short ones 3
|
||||
|
||||
@@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
mod rasterfollow;
|
||||
mod scale;
|
||||
mod transformfx;
|
||||
|
||||
@@ -1251,6 +1252,96 @@ fn roughen_smooth_makes_no_cusps() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roughen_at_size_0_keeps_the_outline_and_spaces_its_points_evenly() {
|
||||
use vectorcraft_geom::AnchorKind;
|
||||
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||||
let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let curve = PathData::single(SubPath::new(
|
||||
vec![
|
||||
vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)),
|
||||
vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b),
|
||||
],
|
||||
false,
|
||||
));
|
||||
let ellipse = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 50.0));
|
||||
// Two subpaths with lines and curves: a closed one whose last segment is a line, and an open
|
||||
// one that ends with a line.
|
||||
let (c, d, e, f) = (Point::new(100.0, 100.0), Point::new(200.0, 0.0), Point::new(300.0, 0.0), Point::new(300.0, 100.0));
|
||||
let mixed = PathData::new(vec![
|
||||
SubPath::new(
|
||||
vec![
|
||||
vectorcraft_geom::Anchor::corner(a),
|
||||
vectorcraft_geom::Anchor::with_handles(Point::new(100.0, 0.0), Point::new(100.0, 0.0), Point::new(160.0, 20.0)),
|
||||
vectorcraft_geom::Anchor::with_handles(c, Point::new(160.0, 80.0), c),
|
||||
],
|
||||
true,
|
||||
),
|
||||
SubPath::new(
|
||||
vec![
|
||||
vectorcraft_geom::Anchor::with_handles(d, d, Point::new(220.0, 60.0)),
|
||||
vectorcraft_geom::Anchor::with_handles(e, Point::new(280.0, 60.0), e),
|
||||
vectorcraft_geom::Anchor::corner(f),
|
||||
],
|
||||
false,
|
||||
),
|
||||
]);
|
||||
for path in [square(), ellipse, curve, mixed] {
|
||||
let all: Vec<_> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect();
|
||||
let off = |q: Point| all.iter().map(|c| c.nearest(q, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt();
|
||||
for params in
|
||||
[json!({"size": 0}), json!({"size": 0, "points": "corner"}), json!({"size": 0, "relative": false, "points": "corner", "seed": 7})]
|
||||
{
|
||||
let out = run("distort.roughen", params.clone(), &path);
|
||||
assert!(out.anchor_count() > path.anchor_count(), "{params}: the points are added");
|
||||
assert_eq!(out.subpaths.len(), path.subpaths.len(), "{params}");
|
||||
for (sp, got) in path.subpaths.iter().zip(&out.subpaths) {
|
||||
assert_eq!(got.closed, sp.closed);
|
||||
// Walk the result one original segment at a time: its pieces end on the segment's end anchor.
|
||||
let mut at = 0;
|
||||
for i in 0..sp.segment_count() {
|
||||
let (orig, end) = (sp.segment(i), sp.anchors[(i + 1) % sp.anchors.len()]);
|
||||
let start = got.anchors[at];
|
||||
let was = sp.anchors[i];
|
||||
assert_eq!((start.p, start.kind, start.has_in(), start.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}");
|
||||
let mut pieces = vec![];
|
||||
loop {
|
||||
let piece = got.segment(at);
|
||||
for j in 0..=8 {
|
||||
let q = piece.eval(j as f64 / 8.0);
|
||||
assert!(off(q) < 1e-6, "{params}: {q:?} is {} off the outline", off(q));
|
||||
}
|
||||
pieces.push(piece.arclen(1e-6));
|
||||
at += 1;
|
||||
if piece.p3.distance(end.p) < 1e-9 {
|
||||
break;
|
||||
}
|
||||
// A point the effect added: a corner without handles on a line, a smooth
|
||||
// point whose handles follow the curve.
|
||||
let added = got.anchors[at];
|
||||
if sp.segment_is_line(i) {
|
||||
assert_eq!((added.kind, added.has_in(), added.has_out()), (AnchorKind::Corner, false, false), "{params}");
|
||||
} else {
|
||||
assert_eq!(added.kind, AnchorKind::Smooth, "{params}");
|
||||
let (hi, ho) = (added.h_in - added.p, added.h_out - added.p);
|
||||
assert!(hi.cross(ho).abs() < 1e-6 * hi.hypot() * ho.hypot() && hi.dot(ho) < 0.0, "{params}: {added:?}");
|
||||
}
|
||||
}
|
||||
assert!(pieces.len() > 1, "{params}: segment {i} is split");
|
||||
assert!(close(pieces.iter().sum::<f64>(), orig.arclen(1e-6), 1e-3), "{params}: {pieces:?}");
|
||||
let (lo, hi) = pieces.iter().fold((f64::INFINITY, 0.0f64), |(lo, hi), &l| (lo.min(l), hi.max(l)));
|
||||
assert!(hi - lo < 1e-3, "{params}: segment {i}'s pieces from {lo} to {hi}");
|
||||
}
|
||||
assert_eq!(at, got.segment_count(), "{params}");
|
||||
if !sp.closed {
|
||||
let (last, was) = (got.anchors[at], sp.anchors[sp.anchors.len() - 1]);
|
||||
assert_eq!((last.p, last.kind, last.has_in(), last.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_ridge_count_and_amplitude() {
|
||||
let out = run("distort.zigZag", json!({"ridges": 4, "points": "corner", "size": 10}), &square());
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Raster effects listed before a Transform effect follow it: the Transform effect transforms
|
||||
//! what they paint.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use crate::{RasterFx, bake_document, expand_outlined, raster_effects};
|
||||
|
||||
fn fx(id: &str, params: Value) -> Effect {
|
||||
Effect { id: id.into(), params, visible: true }
|
||||
}
|
||||
|
||||
fn shadow(x: f64, y: f64, blur: f64) -> Effect {
|
||||
fx("stylize.dropShadow", json!({"x": x, "y": y, "blur": blur}))
|
||||
}
|
||||
|
||||
fn transform(params: Value) -> Effect {
|
||||
fx("distort.transform", params)
|
||||
}
|
||||
|
||||
/// The offset and blur of the first Drop Shadow `effects` draw.
|
||||
fn drawn_shadow(effects: &[Effect]) -> (f64, f64, f64) {
|
||||
match raster_effects(effects).first() {
|
||||
Some(RasterFx::DropShadow { dx, dy, blur, .. }) => (*dx, *dy, *blur),
|
||||
other => panic!("expected a drop shadow, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: (f64, f64, f64), b: (f64, f64, f64)) -> bool {
|
||||
(a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 && (a.2 - b.2).abs() < 1e-9
|
||||
}
|
||||
|
||||
/// A 50 pt red square with `effects`.
|
||||
fn square(id: u64, effects: Vec<Effect>) -> Node {
|
||||
let mut n = Node::path(
|
||||
NodeId(id),
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)),
|
||||
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects = effects;
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_after_a_drop_shadow_scales_its_offset_and_blur() {
|
||||
for strokes in [false, true] {
|
||||
let effects = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200, "strokes": strokes}))];
|
||||
assert!(close(drawn_shadow(&effects), (20.0, 20.0, 8.0)), "Scale Strokes & Effects {strokes}: {:?}", drawn_shadow(&effects));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drop_shadow_after_a_transform_keeps_its_offset() {
|
||||
let effects = [transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 10.0, 4.0)];
|
||||
assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_offset_follows_an_uneven_scale_a_rotation_and_a_reflection() {
|
||||
let uneven = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 100}))];
|
||||
assert!(close(drawn_shadow(&uneven), (20.0, 10.0, 4.0 * 2f64.sqrt())), "{:?}", drawn_shadow(&uneven));
|
||||
// A turn of 90° counterclockwise on screen takes an offset to the right to one upward.
|
||||
let turned = [shadow(10.0, 0.0, 4.0), transform(json!({"rotate": 90}))];
|
||||
let (dx, dy, blur) = drawn_shadow(&turned);
|
||||
assert!(dx.abs() < 1e-9 && (dy + 10.0).abs() < 1e-9 && (blur - 4.0).abs() < 1e-9, "{dx} {dy} {blur}");
|
||||
let reflected = [shadow(10.0, 0.0, 4.0), transform(json!({"reflectX": true}))];
|
||||
assert!(close(drawn_shadow(&reflected), (-10.0, 0.0, 4.0)), "{:?}", drawn_shadow(&reflected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_after_the_shadow_apply_in_order() {
|
||||
let effects = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(json!({"rotate": 90}))];
|
||||
let (dx, dy, _) = drawn_shadow(&effects);
|
||||
assert!(dx.abs() < 1e-9 && (dy + 20.0).abs() < 1e-9, "{dx} {dy}");
|
||||
// Only the Transform after a shadow moves it.
|
||||
let between = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), shadow(0.0, 10.0, 0.0)];
|
||||
let drawn = raster_effects(&between);
|
||||
let offsets: Vec<(f64, f64)> = drawn
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
RasterFx::DropShadow { dx, dy, .. } => Some((*dx, *dy)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(offsets, vec![(20.0, 0.0), (0.0, 10.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_that_leave_the_art_or_copy_it_leave_the_shadow() {
|
||||
for params in [
|
||||
json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}),
|
||||
json!({"scaleH": 200, "scaleV": 200, "copies": 1}),
|
||||
json!({"scaleH": 200, "scaleV": 200, "random": true}),
|
||||
] {
|
||||
let effects = [shadow(10.0, 10.0, 4.0), transform(params.clone())];
|
||||
assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{params}: {:?}", drawn_shadow(&effects));
|
||||
}
|
||||
let mut hidden = transform(json!({"scaleH": 200, "scaleV": 200}));
|
||||
hidden.visible = false;
|
||||
let effects = [shadow(10.0, 10.0, 4.0), hidden];
|
||||
assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects));
|
||||
}
|
||||
|
||||
/// A hidden shadow kept when the Transform effect is applied (Expand Appearance) draws as it would
|
||||
/// have before, once it is shown.
|
||||
#[test]
|
||||
fn a_hidden_shadow_follows_the_transform_it_loses() {
|
||||
let mut hidden = shadow(10.0, 10.0, 4.0);
|
||||
hidden.visible = false;
|
||||
let kept = crate::raster::without_geometry(&[hidden, transform(json!({"scaleH": 200, "scaleV": 200}))]);
|
||||
assert_eq!(kept.len(), 1);
|
||||
let mut shown = kept[0].clone();
|
||||
shown.visible = true;
|
||||
assert!(close(drawn_shadow(&[shown]), (20.0, 20.0, 8.0)));
|
||||
}
|
||||
|
||||
/// Rasterize keeps its room around the art: its image keeps its own pixel grid.
|
||||
#[test]
|
||||
fn rasterize_before_a_transform_keeps_its_options() {
|
||||
let rasterize = fx(crate::RASTERIZE, json!({"addAround": 6}));
|
||||
let effects = [rasterize.clone(), transform(json!({"scaleH": 200, "scaleV": 200}))];
|
||||
assert_eq!(crate::raster::followed(&effects)[0], rasterize);
|
||||
assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]);
|
||||
}
|
||||
|
||||
/// An SVG filter keeps its options: it paints in its own user space.
|
||||
#[test]
|
||||
fn an_svg_filter_before_a_transform_keeps_its_options() {
|
||||
let filter = fx(crate::SVG_FILTER, json!({"origin": [10, 10]}));
|
||||
let effects = [filter.clone(), transform(json!({"scaleH": 200, "scaleV": 200, "rotate": 90}))];
|
||||
assert_eq!(crate::raster::followed(&effects)[0], filter);
|
||||
assert_eq!(crate::raster::without_geometry(&effects), vec![filter]);
|
||||
}
|
||||
|
||||
/// The offset of every Drop Shadow drawn on `n` and the nodes under it, from the object's effects
|
||||
/// and from each fill's and stroke's.
|
||||
fn shadows_in(n: &Node) -> Vec<(f64, f64)> {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |m| {
|
||||
let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects()));
|
||||
for list in lists {
|
||||
for r in raster_effects(list) {
|
||||
if let RasterFx::DropShadow { dx, dy, .. } = r {
|
||||
out.push((dx, dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(p, q)| (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9)
|
||||
}
|
||||
|
||||
/// A square whose fill carries `effects` of its own.
|
||||
fn fill_square(id: u64, effects: Vec<Effect>) -> Node {
|
||||
let mut n = square(id, vec![]);
|
||||
if let Some(item) = n.appearance.items.first_mut() {
|
||||
*item.effects_mut() = effects;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// A fill's own Transform effect transforms the fill's raster effects before it: drawn as they are
|
||||
/// (no options), drawn through the art the Transform Patterns or Scale Strokes & Effects option
|
||||
/// makes, expanded and baked. Scale Strokes & Effects does not scale a shadow that follows the
|
||||
/// Transform effect a second time.
|
||||
#[test]
|
||||
fn a_fills_shadow_follows_the_fills_own_transform() {
|
||||
let cases = [
|
||||
(json!({"rotate": 90}), (0.0, -10.0)),
|
||||
(json!({"rotate": 90, "strokes": true}), (0.0, -10.0)),
|
||||
(json!({"scaleH": 200, "scaleV": 200, "strokes": true}), (20.0, 0.0)),
|
||||
(json!({"scaleH": 200, "scaleV": 200, "patterns": true}), (20.0, 0.0)),
|
||||
(json!({"reflectX": true, "patterns": true}), (-10.0, 0.0)),
|
||||
];
|
||||
for (params, want) in cases {
|
||||
let n = fill_square(11, vec![shadow(10.0, 0.0, 0.0), transform(params.clone())]);
|
||||
// Drawn and expanded through its art when an option makes some.
|
||||
if crate::has_transform_art(&n) {
|
||||
let art = crate::reshape(None, &n, None).unwrap();
|
||||
assert!(near(&shadows_in(&art), &[want]), "{params}: drawn {:?}", shadows_in(&art));
|
||||
let expanded = expand_outlined(None, &n, None).unwrap();
|
||||
assert!(near(&shadows_in(&expanded), &[want]), "{params}: expanded {:?}", shadows_in(&expanded));
|
||||
} else {
|
||||
assert!(near(&shadows_in(&n), &[want]), "{params}: drawn {:?}", shadows_in(&n));
|
||||
}
|
||||
let mut d = Document::new(800.0, 800.0);
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
let baked = bake_document(&d).unwrap();
|
||||
let b = baked.node(NodeId(11)).unwrap();
|
||||
assert!(near(&shadows_in(b), &[want]), "{params}: baked {:?}", shadows_in(b));
|
||||
}
|
||||
}
|
||||
|
||||
/// A Transform effect after a fill's own Transform effect with Scale Strokes & Effects on turns the
|
||||
/// shadow between them, which the first one leaves as it is; a Transform effect that moves at random
|
||||
/// holds the shadow before it back from the Transform effects after it.
|
||||
#[test]
|
||||
fn a_fills_later_transform_turns_the_shadow_after_its_first() {
|
||||
let n = fill_square(
|
||||
12,
|
||||
vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0), transform(json!({"rotate": 90}))],
|
||||
);
|
||||
let art = crate::reshape(None, &n, None).unwrap();
|
||||
assert!(near(&shadows_in(&art), &[(0.0, -10.0)]), "{:?}", shadows_in(&art));
|
||||
let held = fill_square(
|
||||
13,
|
||||
vec![
|
||||
shadow(10.0, 0.0, 0.0),
|
||||
transform(json!({"scaleH": 200, "scaleV": 200, "random": true, "strokes": true})),
|
||||
transform(json!({"rotate": 90})),
|
||||
],
|
||||
);
|
||||
let art = crate::reshape(None, &held, None).unwrap();
|
||||
let drawn = shadows_in(&art);
|
||||
assert!(drawn.len() == 1 && drawn[0].0 > 0.0 && drawn[0].1.abs() < 1e-9, "{drawn:?}");
|
||||
}
|
||||
|
||||
/// A fill's own Transform effect with Transform Patterns or Scale Strokes & Effects on leaves the
|
||||
/// fill's raster effects after it as they are: they paint around the transformed art, its copies
|
||||
/// included. With Transform Objects off it leaves the raster effects before it as they are too.
|
||||
/// Drawn, expanded and baked alike.
|
||||
#[test]
|
||||
fn a_fills_own_transform_leaves_the_shadow_after_it() {
|
||||
let cases = [
|
||||
vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0)],
|
||||
vec![transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true, "strokes": true})), shadow(10.0, 0.0, 0.0)],
|
||||
vec![transform(json!({"scaleH": 200, "scaleV": 200, "moveH": 120, "copies": 1, "strokes": true})), shadow(10.0, 0.0, 0.0)],
|
||||
vec![transform(json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true})), shadow(10.0, 0.0, 0.0)],
|
||||
vec![shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}))],
|
||||
// A later Transform effect with an option leaves the shadow after it as well.
|
||||
vec![
|
||||
transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true})),
|
||||
transform(json!({"scaleH": 150, "scaleV": 150, "strokes": true})),
|
||||
shadow(10.0, 0.0, 0.0),
|
||||
],
|
||||
];
|
||||
for effects in cases {
|
||||
let n = fill_square(18, effects.clone());
|
||||
let art = crate::reshape(None, &n, None).unwrap();
|
||||
let drawn = shadows_in(&art);
|
||||
assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{effects:?}: drawn {drawn:?}");
|
||||
let expanded = shadows_in(&expand_outlined(None, &n, None).unwrap());
|
||||
assert!(!expanded.is_empty() && near(&expanded, &vec![(10.0, 0.0); expanded.len()]), "{effects:?}: expanded {expanded:?}");
|
||||
let mut d = Document::new(800.0, 800.0);
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
let baked = bake_document(&d).unwrap();
|
||||
let b = shadows_in(baked.node(NodeId(18)).unwrap());
|
||||
assert!(!b.is_empty() && near(&b, &vec![(10.0, 0.0); b.len()]), "{effects:?}: baked {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A geometry effect after a fill's own Transform effect with Scale Strokes & Effects on reshapes
|
||||
/// the transformed art by its own distance, as it does after an object's Transform effect.
|
||||
#[test]
|
||||
fn a_fills_own_transform_leaves_the_offset_after_it() {
|
||||
let offset = fx("path.offsetPath", json!({"offset": 5}));
|
||||
let scaled = transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true}));
|
||||
let n = fill_square(19, vec![scaled.clone(), offset.clone()]);
|
||||
let on_fill = crate::reshape(None, &n, None).unwrap();
|
||||
let on_object = crate::reshape(None, &square(20, vec![scaled, offset]), None).unwrap();
|
||||
let (fill, object) = (on_fill.geometric_bounds().unwrap(), on_object.geometric_bounds().unwrap());
|
||||
// The 50 pt square scaled to 100 pt about its center, then offset by 5 pt on each side.
|
||||
assert!((fill.width() - 110.0).abs() < 1e-6 && (fill.height() - 110.0).abs() < 1e-6, "{fill:?}");
|
||||
assert!((fill.width() - object.width()).abs() < 1e-6 && (fill.x0 - object.x0).abs() < 1e-6, "{fill:?} {object:?}");
|
||||
let expanded = expand_outlined(None, &n, None).unwrap().geometric_bounds().unwrap();
|
||||
assert!((expanded.width() - 110.0).abs() < 1e-6 && (expanded.x0 - fill.x0).abs() < 1e-6, "expanded {expanded:?}");
|
||||
}
|
||||
|
||||
/// The SVG filters on `n` and the nodes under it, from the object's effects and from each fill's
|
||||
/// and stroke's.
|
||||
fn filters_in(n: &Node) -> Vec<Effect> {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |m| {
|
||||
let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects()));
|
||||
out.extend(lists.flatten().filter(|e| e.id == crate::SVG_FILTER).cloned());
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// An SVG filter after a fill's own Transform effect with Scale Strokes & Effects on keeps its
|
||||
/// scale and the origin of its user space, as it does after the same Transform effect without
|
||||
/// options and after an object's own Transform effect.
|
||||
#[test]
|
||||
fn a_fills_own_transform_leaves_the_svg_filter_after_it() {
|
||||
let filter = fx(crate::SVG_FILTER, json!({"scale": 1.0, "origin": [10.0, 10.0]}));
|
||||
for params in [
|
||||
json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "strokes": true}),
|
||||
json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "patterns": true}),
|
||||
json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1, "strokes": true}),
|
||||
json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}),
|
||||
] {
|
||||
let art = crate::reshape(None, &fill_square(21, vec![transform(params.clone()), filter.clone()]), None).unwrap();
|
||||
let drawn = filters_in(&art);
|
||||
assert!(!drawn.is_empty() && drawn.iter().all(|e| *e == filter), "{params}: {drawn:?}");
|
||||
}
|
||||
let object = square(22, vec![transform(json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "strokes": true})), filter.clone()]);
|
||||
let art = crate::reshape(None, &object, None).unwrap();
|
||||
assert!(!filters_in(&art).is_empty() && filters_in(&art).iter().all(|e| *e == filter), "{:?}", filters_in(&art));
|
||||
}
|
||||
|
||||
/// The 3D effects keep the shadow before a Transform effect that shapes the art they project, the
|
||||
/// object's and its fill's 3D effect alike.
|
||||
#[test]
|
||||
fn a_shadow_before_a_transform_follows_it_into_3d_art() {
|
||||
let before = vec![shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))];
|
||||
for three_d in [crate::EXTRUDE, crate::REVOLVE] {
|
||||
let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) };
|
||||
let mut on_object = before.clone();
|
||||
on_object.push(fx(three_d, json!({})));
|
||||
let projected = art(&square(14, on_object)).unwrap();
|
||||
assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the object: {:?}", shadows_in(&projected));
|
||||
let mut on_fill = square(15, before.clone());
|
||||
if let Some(item) = on_fill.appearance.items.first_mut() {
|
||||
*item.effects_mut() = vec![fx(three_d, json!({}))];
|
||||
}
|
||||
let projected = art(&on_fill).unwrap();
|
||||
assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the fill: {:?}", shadows_in(&projected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glows_blurs_and_feathers_scale_with_the_transform_after_them() {
|
||||
let effects = [
|
||||
fx("stylize.outerGlow", json!({"blur": 5})),
|
||||
fx("stylize.innerGlow", json!({"blur": 5})),
|
||||
fx("stylize.feather", json!({"radius": 5})),
|
||||
fx("blur.gaussian", json!({"radius": 5})),
|
||||
transform(json!({"scaleH": 50, "scaleV": 50})),
|
||||
];
|
||||
let sizes: Vec<f64> = raster_effects(&effects)
|
||||
.iter()
|
||||
.map(|r| match r {
|
||||
RasterFx::OuterGlow { blur, .. } | RasterFx::InnerGlow { blur, .. } => *blur,
|
||||
RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius, .. } => *radius,
|
||||
other => panic!("unexpected {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(sizes, vec![2.5; 4]);
|
||||
// How far the shadow paints past the object follows too.
|
||||
let shadowed = [shadow(10.0, 10.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))];
|
||||
assert_eq!(crate::outset(&shadowed, Rect::new(0.0, 0.0, 100.0, 100.0)), 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_filter_sizes_scale_with_the_transform_after_them() {
|
||||
let effects = [
|
||||
fx("artistic.coloredPencil", json!({"pencilWidth": 5})),
|
||||
fx("artistic.paintDaubs", json!({"brushSize": 10})),
|
||||
transform(json!({"scaleH": 200, "scaleV": 200})),
|
||||
];
|
||||
let drawn = raster_effects(&effects);
|
||||
assert!(matches!(drawn.first(), Some(RasterFx::Pixel(crate::PixelFx::ColoredPencil { width, .. })) if (width - 10.0).abs() < 1e-9), "{drawn:?}");
|
||||
assert!(matches!(drawn.get(1), Some(RasterFx::Pixel(crate::PixelFx::PaintDaubs { size, .. })) if (size - 20.0).abs() < 1e-9), "{drawn:?}");
|
||||
}
|
||||
|
||||
/// A Transform effect after a 3D effect that makes copies or moves at random holds back the shadow
|
||||
/// before the 3D effect, as it does without the 3D effect.
|
||||
#[test]
|
||||
fn a_copying_transform_after_3d_holds_the_shadow_back() {
|
||||
for later in [json!({"copies": 1}), json!({"scaleH": 150, "scaleV": 150, "random": true})] {
|
||||
let plain = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(later.clone())];
|
||||
assert!(close(drawn_shadow(&plain), (10.0, 0.0, 0.0)), "{later}: {:?}", drawn_shadow(&plain));
|
||||
for three_d in [crate::EXTRUDE, crate::REVOLVE] {
|
||||
let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) };
|
||||
let effects = vec![plain[0].clone(), plain[1].clone(), fx(three_d, json!({})), plain[2].clone()];
|
||||
let projected = art(&square(16, effects)).unwrap();
|
||||
let drawn = shadows_in(&projected);
|
||||
assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{three_d} {later}: {drawn:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In a fill's own effects, a Transform effect that makes copies after a Transform effect with
|
||||
/// Transform Patterns on holds back the shadow before both, and before a Transform effect between
|
||||
/// them.
|
||||
#[test]
|
||||
fn a_fills_copying_transform_holds_back_the_shadow_before_an_earlier_transform() {
|
||||
let patterns = transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true}));
|
||||
let copies = transform(json!({"copies": 1}));
|
||||
let lists = [
|
||||
vec![shadow(10.0, 0.0, 0.0), patterns.clone(), copies.clone()],
|
||||
vec![shadow(10.0, 0.0, 0.0), patterns, transform(json!({"rotate": 90})), copies],
|
||||
];
|
||||
for effects in lists {
|
||||
let n = fill_square(17, effects.clone());
|
||||
let art = crate::reshape(None, &n, None).unwrap();
|
||||
let drawn = shadows_in(&art);
|
||||
assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{} effects: {drawn:?}", effects.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_and_exported_objects_keep_the_shadow_the_canvas_draws() {
|
||||
let effects = vec![shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200}))];
|
||||
// A path drawn through its art (Scale Strokes & Effects on), expanded.
|
||||
let mut with_options = effects.clone();
|
||||
with_options[1] = transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true}));
|
||||
let n = square(7, with_options);
|
||||
let expanded = expand_outlined(None, &n, None).unwrap();
|
||||
assert!(close(drawn_shadow(&expanded.appearance.effects), (20.0, 20.0, 8.0)), "{:?}", expanded.appearance.effects);
|
||||
// A path and a group, baked for export.
|
||||
let mut d = Document::new(800.0, 800.0);
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, square(8, effects.clone())).unwrap();
|
||||
let mut g = Node::group(NodeId(9), vec![std::sync::Arc::new(square(10, vec![]))]);
|
||||
g.appearance.effects = effects.clone();
|
||||
d.insert(Some(layer), 1, g).unwrap();
|
||||
let baked = bake_document(&d).unwrap();
|
||||
for id in [8, 9] {
|
||||
let b = baked.node(NodeId(id)).unwrap();
|
||||
assert!(!b.appearance.effects.iter().any(|e| e.id == "distort.transform"), "{id}: {:?}", b.appearance.effects);
|
||||
assert!(close(drawn_shadow(&b.appearance.effects), (20.0, 20.0, 8.0)), "{id}: {:?}", b.appearance.effects);
|
||||
}
|
||||
}
|
||||
@@ -222,7 +222,7 @@ fn each_copy_of_a_strokes_own_transform_scales_its_weight_once_more() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects() {
|
||||
fn a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects() {
|
||||
let mut n = stroke_with_effect(json!({}));
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
|
||||
f.effects.push(transform(json!({"scaleH": 300, "scaleV": 300, "strokes": true})));
|
||||
@@ -231,10 +231,10 @@ fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects
|
||||
let art = reshape(None, &n, None).unwrap();
|
||||
let fill = leaves(&art).into_iter().find(|p| p.appearance.stroke().is_none()).unwrap();
|
||||
let AppearanceItem::Fill(f) = &fill.appearance.items[0] else { panic!("not a fill") };
|
||||
// The Offset Path, scaled from 5 to 15, applies to the transformed fill's path.
|
||||
// The Offset Path applies to the transformed fill's path at its own 5 pt.
|
||||
assert!(f.effects.is_empty(), "{:?}", f.effects);
|
||||
let w = fill.geometric_bounds().unwrap().width();
|
||||
assert!(close(w, 330.0), "{w}");
|
||||
assert!(close(w, 310.0), "{w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -137,7 +137,7 @@ fn legend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let parent = st.target_parent()?;
|
||||
let text_size = (size / 7.0).clamp(4.0, 12.0);
|
||||
let pad = (size / 12.0).clamp(1.0, 8.0);
|
||||
let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size }));
|
||||
let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size }), C)?;
|
||||
let count = swatches.len();
|
||||
// Each chip's fill links to its swatch, as paint.setFill {swatch} links art: editing a global or
|
||||
// spot swatch then recolours the legend too.
|
||||
|
||||
@@ -24,6 +24,7 @@ use vectorcraft_geom::Affine;
|
||||
|
||||
use super::super::fileio::{MAX_RASTER_ALLOC, ppi};
|
||||
use super::super::menucmds::has_image;
|
||||
use super::super::paint::{PROCESS_ON_TRANSPARENCY, gray_colorized_with_process};
|
||||
use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param};
|
||||
|
||||
/// Remove Background's command.
|
||||
@@ -43,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Remove Background…",
|
||||
["Object", "Plug-ins", "Image Tools"],
|
||||
None,
|
||||
"{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}",
|
||||
"{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, a result with no pixel left, and a grayscale image colorized with a process color (which can't colorize gray pixels with transparency) are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}",
|
||||
has_image,
|
||||
remove_background
|
||||
)];
|
||||
@@ -171,6 +172,14 @@ fn remove_background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// taken from and kept in `kept` when it is given.
|
||||
fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result<Vec<(NodeId, Cleared, Affine)>> {
|
||||
let images = images(s, p, C)?;
|
||||
// The result has an alpha channel, which a grayscale image colorized with a process color
|
||||
// can't take.
|
||||
let st = s.doc()?;
|
||||
for (id, ..) in &images {
|
||||
if st.doc.node(*id).is_some_and(|n| gray_colorized_with_process(&st.doc, n).is_some()) {
|
||||
return Err(bad(C, PROCESS_ON_TRANSPARENCY));
|
||||
}
|
||||
}
|
||||
// Each image's pixels once, however many objects show them.
|
||||
let mut cleared: HashMap<&str, Option<Cleared>> = HashMap::new();
|
||||
let mut changes = vec![];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use image::{DynamicImage, ImageBuffer, Luma, Rgb};
|
||||
use image::{DynamicImage, ImageBuffer, Luma, LumaA, Rgb};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
@@ -333,6 +334,54 @@ fn sixteen_bit_and_gray_images_keep_their_depth_and_channels() {
|
||||
assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535]));
|
||||
}
|
||||
|
||||
/// A process color can't colorize a grayscale image with transparency, so a gray image colorized
|
||||
/// with one keeps its pixels. Black, white and spot colors can, so those images are cleared.
|
||||
#[test]
|
||||
fn a_gray_image_colorized_with_a_process_color_is_refused() {
|
||||
let gray = || {
|
||||
png(DynamicImage::ImageLuma8(ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 30u8 } else { 240 }]))))
|
||||
};
|
||||
let (mut s, id) = open(&gray());
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the fill colorizes the image");
|
||||
let (before, steps) = (s.doc().unwrap().doc.clone(), undo_len(&s));
|
||||
let e = s.execute(C, &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("a process color can't colorize a grayscale image with transparency"), "{e}");
|
||||
assert_eq!((&s.doc().unwrap().doc, undo_len(&s)), (&before, steps), "the document and its undo steps are unchanged");
|
||||
// Another image that already has an alpha channel and a process colorize color, as an opened
|
||||
// file can hold, neither lets this one through nor stops a run on an image that can be cleared.
|
||||
let held = ImageBlob::png(png(DynamicImage::ImageLumaA8(ImageBuffer::from_pixel(2, 2, LumaA([128u8, 100])))));
|
||||
let other = place_linked(&mut s, "held", held, (2, 2));
|
||||
s.edit("Embed and colorize", |d, _| {
|
||||
let n = d.node_mut(other).unwrap();
|
||||
n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
if let NodeKind::Image(im) = &mut n.kind {
|
||||
im.link = None;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (before, steps) = (s.doc().unwrap().doc.clone(), undo_len(&s));
|
||||
let e = s.execute(C, &json!({"ids": [id.0]})).unwrap_err();
|
||||
assert!(e.to_string().contains("a process color can't colorize a grayscale image with transparency"), "{e}");
|
||||
assert_eq!((&s.doc().unwrap().doc, undo_len(&s)), (&before, steps), "with another image refused already, nothing changes");
|
||||
s.execute("paint.setFill", &json!({"color": "#000000", "ids": [id.0]})).unwrap();
|
||||
assert_eq!(s.execute(C, &json!({"ids": [id.0]})).unwrap()["changed"], 1, "an image colorized with black is cleared beside the other");
|
||||
let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") };
|
||||
assert_eq!(out.get_pixel(0, 0).0[1], 0, "the background is cleared");
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the image stays colorized");
|
||||
for fill in [json!({"color": "#000000"}), json!({"color": "#ffffff"}), json!({"swatch": "Ink"})] {
|
||||
let (mut s, id) = open(&gray());
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 0, "m": 100, "y": 100, "k": 0}, "spot": true})).unwrap();
|
||||
s.execute("paint.setFill", &fill).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "{fill} colorizes the image");
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") };
|
||||
assert_eq!(out.get_pixel(0, 0).0[1], 0, "{fill}: the background is cleared");
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "{fill} still colorizes the image");
|
||||
}
|
||||
}
|
||||
|
||||
/// The ICC profile the colors are in stays with them.
|
||||
#[test]
|
||||
fn the_color_profile_is_kept() {
|
||||
|
||||
@@ -139,7 +139,8 @@ fn text_tweaker(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut run = 0;
|
||||
for (i, at) in chars.into_iter().enumerate() {
|
||||
let random = if shape == "random" { unit(&mut rng) } else { 0.0 };
|
||||
let value = from + (to - from) * amount(shape, i, n, random);
|
||||
// Rounding can carry a value a hair past the range's ends.
|
||||
let value = (from + (to - from) * amount(shape, i, n, random)).clamp(lo, hi);
|
||||
let mut change = CharChange::parse(&json!({ key: value }), C, &setup)?;
|
||||
change.localize(&t, C)?;
|
||||
while ends.get(run).is_some_and(|&e| e <= at) {
|
||||
|
||||
@@ -170,6 +170,7 @@ pub struct BuiltinBrushLibrary {
|
||||
/// The built-in brush libraries, in menu order: original brushes generated in code.
|
||||
pub const BUILTIN: &[BuiltinBrushLibrary] = &[
|
||||
BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons },
|
||||
BuiltinBrushLibrary { id: "ink-and-wash", name: "Ink and Wash", make: ink_and_wash },
|
||||
BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs },
|
||||
BuiltinBrushLibrary { id: "bristle-brushes", name: "Bristle Brushes", make: bristle_brushes },
|
||||
BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders },
|
||||
@@ -268,6 +269,78 @@ fn art_ribbons() -> BrushLibrary {
|
||||
built("Art Ribbons", doc, brushes)
|
||||
}
|
||||
|
||||
/// A band between the edges y = `top(x)` and y = `bottom(x)` for x in `from`..`to`, in `n` steps.
|
||||
fn between(from: f64, to: f64, n: usize, top: impl Fn(f64) -> f64, bottom: impl Fn(f64) -> f64) -> PathData {
|
||||
let xs: Vec<f64> = (0..=n).map(|i| from + (to - from) * i as f64 / n as f64).collect();
|
||||
let mut pts: Vec<Point> = xs.iter().map(|&x| Point::new(x, top(x))).collect();
|
||||
pts.extend(xs.iter().rev().map(|&x| Point::new(x, bottom(x))));
|
||||
PathData::single(SubPath::polyline(&pts, true))
|
||||
}
|
||||
|
||||
/// Path `p` filled with gray `hex` at `opacity`: one layer of a wash.
|
||||
fn wash(p: PathData, hex: &str, opacity: f32) -> Node {
|
||||
let mut n = filled(p, Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK)));
|
||||
n.opacity = opacity;
|
||||
n
|
||||
}
|
||||
|
||||
/// How much of a wash's width is left at x: rounded ends, full width from 8 pt in.
|
||||
fn wash_ends(x: f64) -> f64 {
|
||||
let ends = ((x / 8.0).min(1.0) * ((100.0 - x) / 8.0).min(1.0)).max(0.0);
|
||||
0.4 + 0.6 * ends.sqrt()
|
||||
}
|
||||
|
||||
/// Ink and Wash: Tapered Ink, broad near its start and thinning to a fine tip; Brush Pen, a
|
||||
/// rounded nib whose size follows the pen pressure; and two washes of translucent grays, Soft Wash
|
||||
/// with faint edges and Pooling Wash with a darker rim and two light breaks. Black and grays take
|
||||
/// the stroke color as tints of it.
|
||||
fn ink_and_wash() -> BrushLibrary {
|
||||
let tapered = {
|
||||
let half = |x: f64| 3.0 * (x / 22.0).min(1.0).powf(0.6) * ((100.0 - x) / 78.0).min(1.0).powf(1.4);
|
||||
black(between(0.0, 100.0, 48, |x| -0.85 * half(x), |x| 1.15 * half(x)))
|
||||
};
|
||||
let pen = Brush {
|
||||
name: "Brush Pen".into(),
|
||||
kind: BrushKind::Calligraphic(Calligraphic {
|
||||
angle: 40.0,
|
||||
roundness: 75.0,
|
||||
size: 5.0,
|
||||
variation: [0.0, 0.0, 2.5],
|
||||
modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]),
|
||||
}),
|
||||
};
|
||||
let soft = {
|
||||
let top = |x: f64| -(4.5 + 0.5 * (x * 0.21).sin() + 0.3 * (x * 0.57 + 1.3).sin()) * wash_ends(x);
|
||||
let bottom = |x: f64| (4.5 + 0.45 * (x * 0.26 + 2.1).sin() + 0.3 * (x * 0.69 + 0.4).sin()) * wash_ends(x);
|
||||
group(vec![
|
||||
wash(between(0.0, 100.0, 40, top, bottom), "#404040", 0.4),
|
||||
wash(between(2.0, 98.0, 32, |x| top(x) - 0.7 - 0.3 * (x * 0.43).sin().abs(), |x| top(x) + 0.5), "#404040", 0.2),
|
||||
wash(between(3.0, 97.0, 32, |x| bottom(x) - 0.5, |x| bottom(x) + 0.6 + 0.35 * (x * 0.37 + 1.0).sin().abs()), "#404040", 0.18),
|
||||
wash(shapes::ellipse(Rect::new(21.0, -2.4, 39.0, 0.8)), "#e6e6e6", 0.22),
|
||||
wash(shapes::ellipse(Rect::new(61.0, -0.3, 75.0, 2.3)), "#e6e6e6", 0.18),
|
||||
])
|
||||
};
|
||||
let pooling = {
|
||||
let top = |x: f64| -(4.0 + 0.4 * (x * 0.18 + 0.7).sin() + 0.2 * (x * 0.53).sin()) * wash_ends(x);
|
||||
let bottom = |x: f64| (4.0 + 0.35 * (x * 0.23 + 1.9).sin() + 0.25 * (x * 0.61 + 0.2).sin()) * wash_ends(x);
|
||||
let rim = |x: f64, k: f64, phase: f64| 0.35 + k * (0.5 + 0.5 * (x * 0.29 + phase).sin());
|
||||
group(vec![
|
||||
wash(between(0.0, 100.0, 40, top, bottom), "#4d4d4d", 0.35),
|
||||
wash(between(1.0, 99.0, 40, top, |x| top(x) + rim(x, 0.45, 0.0)), "#262626", 0.55),
|
||||
wash(between(1.0, 99.0, 40, |x| bottom(x) - rim(x, 0.5, 1.1), bottom), "#262626", 0.55),
|
||||
wash(polygon(&[(34.0, -2.6), (39.0, -2.8), (36.0, 2.7), (31.0, 2.5)]), "#e6e6e6", 0.2),
|
||||
wash(polygon(&[(70.0, -2.4), (74.0, -2.5), (72.0, 2.6), (68.0, 2.4)]), "#e6e6e6", 0.18),
|
||||
])
|
||||
};
|
||||
let brushes = vec![
|
||||
art("Tapered Ink", tapered, Colorization::Tints),
|
||||
pen,
|
||||
art("Soft Wash", soft, Colorization::Tints),
|
||||
art("Pooling Wash", pooling, Colorization::Tints),
|
||||
];
|
||||
built("Ink and Wash", Document::new(100.0, 100.0), brushes)
|
||||
}
|
||||
|
||||
fn nib(name: &str, angle: f64, roundness: f64, size: f64) -> Brush {
|
||||
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
|
||||
@@ -491,7 +491,7 @@ fn import_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if text.trim().is_empty() {
|
||||
return Err(bad(C, "the text is empty"));
|
||||
}
|
||||
let style = super::super::create::new_type_style(s, &Value::Null);
|
||||
let style = super::super::create::new_type_style(s, &Value::Null, C)?;
|
||||
let system_layout = s.doc()?.doc.setup.system_layout;
|
||||
let layout = |t: &mut TextObject| {
|
||||
t.system_layout = system_layout;
|
||||
|
||||
@@ -210,8 +210,12 @@ fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let remembered = s.store_profile(&info.name, &bytes);
|
||||
// A new file of a working space: the active settings still hold the old one.
|
||||
let working = cms::active_settings();
|
||||
if working.rgb == info.name || working.cmyk == info.name {
|
||||
let is_working = working.rgb == info.name || working.cmyk == info.name;
|
||||
if is_working {
|
||||
cms::set_active(&working).map_err(|e| bad(C, e.to_string()))?;
|
||||
}
|
||||
// A new file of a working space, or of the CMYK profile View → Proof Setup targets: redraw.
|
||||
if is_working || proof_targets(&info.name) {
|
||||
touch_all(s);
|
||||
}
|
||||
Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered}))
|
||||
@@ -233,10 +237,17 @@ fn remove_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, format!("no profile named `{name}`")));
|
||||
}
|
||||
s.unstore_profile(&info.name);
|
||||
touch_all(s);
|
||||
if proof_targets(&info.name) {
|
||||
touch_all(s);
|
||||
}
|
||||
Ok(json!({"removed": info.name}))
|
||||
}
|
||||
|
||||
/// Whether View → Proof Setup targets the CMYK profile `name`.
|
||||
fn proof_targets(name: &str) -> bool {
|
||||
matches!(proof::view().setup.target, ProofTarget::Cmyk(t) if t == name)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_profile(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Err(bad("color.loadProfile", "loading profiles from disk isn't available in the browser"))
|
||||
@@ -274,14 +285,17 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
})?;
|
||||
let mut st = cms::active_settings();
|
||||
let before = st.clone();
|
||||
if let Some(r) = &rgb {
|
||||
st.rgb = r.clone();
|
||||
}
|
||||
if let Some(c) = &cmyk {
|
||||
st.cmyk = c.clone();
|
||||
}
|
||||
cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?;
|
||||
touch_all(s);
|
||||
if st != before {
|
||||
cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?;
|
||||
touch_all(s);
|
||||
}
|
||||
}
|
||||
Ok(json!({"rgb": rgb, "cmyk": cmyk}))
|
||||
}
|
||||
@@ -760,17 +774,33 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(on) = on_param(p, "proof", C)? {
|
||||
v.proof_colors = on;
|
||||
}
|
||||
proof::set_view(v.clone());
|
||||
touch_all(s);
|
||||
store_view(s, v.clone());
|
||||
Ok(view_json(&v))
|
||||
}
|
||||
|
||||
/// Make `v` the proof view and redraw every document. When `v` equals the current view, the view
|
||||
/// and the revisions stay as they are; Separations Preview's visible plates are compared as a set.
|
||||
fn store_view(s: &mut Session, v: proof::ProofView) {
|
||||
let shown = |v: &proof::ProofView| proof::ProofView {
|
||||
separations: v.separations.as_ref().map(|vis| {
|
||||
let mut vis = vis.clone();
|
||||
vis.sort();
|
||||
vis.dedup();
|
||||
vis
|
||||
}),
|
||||
..v.clone()
|
||||
};
|
||||
if shown(&v) != shown(&proof::view()) {
|
||||
proof::set_view(v);
|
||||
touch_all(s);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_view(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut proof::ProofView, Option<bool>)) -> Result<Value> {
|
||||
let on = on_param(p, "on", cmd)?;
|
||||
let mut v = proof::view();
|
||||
f(&mut v, on);
|
||||
proof::set_view(v.clone());
|
||||
touch_all(s);
|
||||
store_view(s, v.clone());
|
||||
Ok(view_json(&v))
|
||||
}
|
||||
|
||||
@@ -802,7 +832,15 @@ fn separations(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match p.get("plates") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Array(a)) if a.iter().all(Value::is_string) => {
|
||||
v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::<Result<Vec<_>>>()?);
|
||||
let mut vis: Vec<String> = Vec::new();
|
||||
for n in a.iter().filter_map(Value::as_str) {
|
||||
let n = check(n)?;
|
||||
// A plate named twice is shown once.
|
||||
if !vis.contains(&n) {
|
||||
vis.push(n);
|
||||
}
|
||||
}
|
||||
v.separations = Some(vis);
|
||||
}
|
||||
Some(_) => {
|
||||
let key = "plates";
|
||||
@@ -821,8 +859,7 @@ fn separations(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
vis.push(n);
|
||||
}
|
||||
}
|
||||
proof::set_view(v);
|
||||
touch_all(s);
|
||||
store_view(s, v);
|
||||
Ok(plates_json(s))
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
"{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt 0.1..1296 (outside it: an error, and no type is made), font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -739,7 +739,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(a, &["width", "height"], "text.create")?;
|
||||
}
|
||||
let text = str_param(p, "text").unwrap_or("");
|
||||
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
|
||||
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p, "text.create")?);
|
||||
// Turned about the point (the first baseline's start, an area's top-left corner): type set
|
||||
// along a Constrain Angle.
|
||||
let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", "text.create")? } else { 0.0 };
|
||||
@@ -848,11 +848,11 @@ pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str, t: &mut TextObject)
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB
|
||||
/// `color` in a CMYK document as [`super::paint::new_color`] stores it), else the defaults,
|
||||
/// filled with the current fill unless that is None or white (black then), in the document's
|
||||
/// language.
|
||||
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
/// language. A `size` outside [`super::typecmd::TYPE_SIZES`] is an error.
|
||||
pub(crate) fn new_type_style(s: &Session, p: &Value, cmd: &str) -> Result<CharStyle> {
|
||||
let mut style = CharStyle::default();
|
||||
if let Some(sz) = p.get("size").and_then(Value::as_f64) {
|
||||
style.size = sz.clamp(0.1, 1296.0);
|
||||
style.size = super::typecmd::given_type_size(sz, cmd)?;
|
||||
}
|
||||
if let Some(f) = str_param(p, "font") {
|
||||
style.font_family = f.to_string();
|
||||
@@ -873,5 +873,5 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
let chosen = |name: &str| vectorcraft_doc::setup::char_language(name).ok().flatten();
|
||||
let document = s.doc().ok().and_then(|d| chosen(&d.doc.setup.language));
|
||||
style.language = document.or_else(|| chosen(&s.prefs.hyphenation_language)).map(str::to_string);
|
||||
style
|
||||
Ok(style)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_svg::{CssOptions, CssSheet, css_rules};
|
||||
|
||||
use super::fileio::{default_name, encode_with_warnings, isolated, write_file};
|
||||
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets};
|
||||
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, named_ids, targets};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -21,13 +21,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
selection
|
||||
),
|
||||
cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document", has_doc, generate),
|
||||
cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document; ids and id are not used, and a malformed one is an error", has_doc, generate),
|
||||
cmd!(
|
||||
"css.export",
|
||||
"Export CSS",
|
||||
[],
|
||||
None,
|
||||
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
|
||||
"{path?: string, scope?: selection|all (default selection when left out; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} a path or scope given as anything but a string, null included, is an error, and so is a malformed ids or id with scope all, which does not use them; write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
@@ -77,16 +77,34 @@ fn selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(result(&sheet(s, p, ids, "css.selection")?))
|
||||
}
|
||||
|
||||
/// Checks the form of the `ids` or `id` that `p` gives a command that exports every object and does
|
||||
/// not use them: a malformed one is an error, as it is for the selection.
|
||||
fn unused_ids(p: &Value) -> Result<()> {
|
||||
named_ids(p).map(|_| ())
|
||||
}
|
||||
|
||||
fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
unused_ids(p)?;
|
||||
let ids = everything(s)?;
|
||||
Ok(result(&sheet(s, p, ids, "css.generate")?))
|
||||
}
|
||||
|
||||
/// The string `p` gives for `key`, or `None` when `p` leaves it out. Any value given that is not a
|
||||
/// string, null included, is an error: "`{key}` must be a string".
|
||||
fn given_text<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<Option<&'a str>> {
|
||||
p.get(key).map(|v| v.as_str().ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose()
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "css.export";
|
||||
let ids = match str_param(p, "scope").unwrap_or("selection") {
|
||||
let scope = given_text(p, "scope", C)?;
|
||||
let path = given_text(p, "path", C)?;
|
||||
let ids = match scope.unwrap_or("selection") {
|
||||
"selection" => targets(s, p)?,
|
||||
"all" => everything(s)?,
|
||||
"all" => {
|
||||
unused_ids(p)?;
|
||||
everything(s)?
|
||||
}
|
||||
other => return Err(bad(C, format!("scope must be selection or all (got {other})"))),
|
||||
};
|
||||
let sheet = sheet(s, p, ids, C)?;
|
||||
@@ -115,7 +133,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let n = sheet.rules.len();
|
||||
match str_param(p, "path") {
|
||||
match path {
|
||||
Some(path) => {
|
||||
write_file(path, css.as_bytes())?;
|
||||
let folder = std::path::Path::new(path).parent();
|
||||
|
||||
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
doc_move
|
||||
),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → path (the document's file; null when automation read roots are in force and the file is outside them), layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find),
|
||||
cmd!(
|
||||
@@ -50,7 +50,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
doc_compare
|
||||
),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, in brush art too, and so is a data set's image value outside them; a relative path that leads outside them is left out; a stored brush entry that does not read as a brush is left out; a folder outside them is left out of export_settings)", has_doc, |s, _| {
|
||||
let st = s.doc()?;
|
||||
let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st));
|
||||
Ok(serde_json::to_value(&*d).unwrap_or(Value::Null))
|
||||
}),
|
||||
cmd!(
|
||||
"document.setUnits",
|
||||
"Units",
|
||||
|
||||
@@ -546,12 +546,14 @@ fn expand_piece_leaves(
|
||||
}
|
||||
|
||||
/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its
|
||||
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
|
||||
/// path (a plain path then), and every geometry effect of its own removed, with each raster effect
|
||||
/// following the Transform effects after it
|
||||
/// ([`without_geometry`](vectorcraft_render::effects::without_geometry)). `None` when it has no
|
||||
/// visible geometry effect. The swatches and patterns that live-effect plug-ins painting their
|
||||
/// pieces name are looked up in `doc`. This merges the pieces of such a plug-in into one path, so a
|
||||
/// path that one paints goes through [`expand_pieces`] instead.
|
||||
pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option<Node> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry};
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, without_geometry};
|
||||
if !has_geometry(&l.appearance.effects) {
|
||||
return None;
|
||||
}
|
||||
@@ -562,7 +564,7 @@ pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option<Node> {
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
m.appearance.effects.retain(|e| !is_geometry(&e.id));
|
||||
m.appearance.effects = without_geometry(&m.appearance.effects);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
@@ -604,9 +606,10 @@ fn with_path(l: &Node, path: PathData) -> Node {
|
||||
}
|
||||
|
||||
/// The effects of fill `f` that stay on its expanded art: all but its geometry effects, which
|
||||
/// shape the outline it paints ([`fill_shape`]).
|
||||
/// shape the outline it paints ([`fill_shape`]), with each raster effect following the Transform
|
||||
/// effects after it ([`without_geometry`](vectorcraft_render::effects::without_geometry)).
|
||||
fn fill_effects(f: &FillLayer) -> Vec<vectorcraft_doc::Effect> {
|
||||
f.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect()
|
||||
vectorcraft_render::effects::without_geometry(&f.effects)
|
||||
}
|
||||
|
||||
/// Replace every pattern fill of the selected paths by the tiles it paints (see [`expand_patterns`]).
|
||||
|
||||
@@ -71,7 +71,6 @@ pub(crate) use encode::{anti_alias, background, with_single_artboard};
|
||||
pub(crate) use export::with_artboard_fills;
|
||||
pub(crate) use export::{export_crop, isolated};
|
||||
pub use export::{export_doc, export_source};
|
||||
pub(crate) use load::check_not_lossy_overwrite;
|
||||
use load::err;
|
||||
pub(crate) use load::import_svg;
|
||||
pub(crate) use load::is_placeholder;
|
||||
@@ -82,6 +81,7 @@ pub use load::{
|
||||
Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image,
|
||||
read_for_open, texture_image,
|
||||
};
|
||||
pub(crate) use load::{check_files_not_lossy_overwrite, check_not_lossy_overwrite, lossy_source};
|
||||
pub use load::{losses_summary, overwrite_losses};
|
||||
pub use mesh3d::{DEFAULT_FORMAT_3D, FORMATS_3D, format_3d_label};
|
||||
pub(crate) use mesh3d::{mesh_file_names, mesh_files};
|
||||
@@ -130,7 +130,8 @@ mod tests_readonly;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use access::{Placed, copy_new, file_metadata};
|
||||
pub(crate) use access::{
|
||||
absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file,
|
||||
absolute_path, create_dir, document_path, file_created, file_stamp, naming_in_error, read_file, read_file_head, shown_link, shown_path,
|
||||
write_file, write_new_file,
|
||||
};
|
||||
#[cfg(all(test, windows))]
|
||||
pub(crate) use access::{document_path_on, document_path_with};
|
||||
|
||||
@@ -65,6 +65,62 @@ pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::bo
|
||||
std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name })
|
||||
}
|
||||
|
||||
/// What an error names a file or folder outside the read roots by when [`shown_path`] gives no
|
||||
/// name for it, or gives one that is not valid text.
|
||||
pub(crate) const UNNAMED: &str = "a file or folder outside the read roots";
|
||||
|
||||
/// An error about `path`, a file or folder the client did not give, as the request being served
|
||||
/// may see it: where the message names `path` ([`naming_in_message`]), it names it as
|
||||
/// [`shown_path`] does, or as [`UNNAMED`] when that is empty or holds a replacement character. The
|
||||
/// roots the message names and the reason stay whole. Unchanged when `path` is [`shown`].
|
||||
pub(crate) fn naming_in_error(e: EngineError, path: &str) -> EngineError {
|
||||
if path.is_empty() || shown(path) {
|
||||
return e;
|
||||
}
|
||||
naming_as(e, path, &error_name(path))
|
||||
}
|
||||
|
||||
/// What an error names `path` by when it is outside the read roots: [`shown_path`], or
|
||||
/// [`UNNAMED`] when that is empty or holds a replacement character.
|
||||
pub(crate) fn error_name(path: &str) -> String {
|
||||
let name = shown_path(path);
|
||||
if name.is_empty() || name.contains('\u{FFFD}') { UNNAMED.to_string() } else { name.into_owned() }
|
||||
}
|
||||
|
||||
/// [`naming_in_error`] with `path` named `name`, whether or not it is [`shown`] (a name worked out
|
||||
/// earlier, for the request that prepared the work).
|
||||
pub(crate) fn naming_as(e: EngineError, path: &str, name: &str) -> EngineError {
|
||||
match e {
|
||||
EngineError::Other(msg) => EngineError::Other(naming_in_message(&msg, path, name)),
|
||||
EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: naming_in_message(&msg, path, name) },
|
||||
EngineError::ReadOnlyLocation(msg) => EngineError::ReadOnlyLocation(naming_in_message(&msg, path, name)),
|
||||
e => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// `msg` with `path` given as `name` where it begins a path the message is about: at the start of
|
||||
/// `msg` (`<path>: <reason>`), after a `: ` (`... outside the write root <root>: <path>`) or after
|
||||
/// `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message names
|
||||
/// stays whole, even one inside `path`.
|
||||
pub(crate) fn naming_in_message(msg: &str, path: &str, name: &str) -> String {
|
||||
const BEFORE: [&str; 2] = [": ", "cannot resolve "];
|
||||
let mut out = String::with_capacity(msg.len());
|
||||
let mut rest = msg;
|
||||
loop {
|
||||
if let Some(after) = rest.strip_prefix(path)
|
||||
&& after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' '))
|
||||
{
|
||||
out.push_str(name);
|
||||
rest = after;
|
||||
}
|
||||
let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break };
|
||||
out.push_str(&rest[..i + len]);
|
||||
rest = &rest[i + len..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// `path` checked for writing against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn may_write(path: &str) -> Result<()> {
|
||||
@@ -466,3 +522,52 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
|
||||
pub(crate) fn write_new_file(path: &str, _: &[u8]) -> Result<()> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
/// An error about a path the client did not give, under roots that don't let it be read: the
|
||||
/// path is named by its file name (after a `/` or a `\`), or as [`UNNAMED`] when it ends in no
|
||||
/// name or its name is not valid text, and every root and the reason stay whole. A path inside the
|
||||
/// read roots, and any path without roots in force, stay whole.
|
||||
#[test]
|
||||
fn an_error_names_a_path_outside_the_read_roots_by_its_name() {
|
||||
let base = std::env::temp_dir().join(format!("vc-access-naming-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (a, b, out) = (base.join("a"), base.join("b"), base.join("kept-out"));
|
||||
for f in [&a, &b, &out] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let text = |p: &std::path::Path| p.to_string_lossy().into_owned();
|
||||
let roots = AutomationRoots::new(&[a.as_path(), b.as_path()], &[a.as_path(), b.as_path()]).unwrap();
|
||||
let file = text(&out.join("doc.svg"));
|
||||
let raw = confine(roots.as_ref(), || crate::file_access::check_write(&file)).unwrap_err();
|
||||
let shown_root = |p: &std::path::Path| crate::cmd::findfiles::plain(std::fs::canonicalize(p).unwrap()).display().to_string();
|
||||
let (ra, rb) = (shown_root(&a), shown_root(&b));
|
||||
let named = |e: EngineError, path: &str| confine(roots.as_ref(), || naming_in_error(e, path)).to_string();
|
||||
|
||||
assert_eq!(named(EngineError::Other(raw.clone()), &file), format!("automation path rejected: outside the write roots {ra}, {rb}: doc.svg"));
|
||||
let io = named(EngineError::Other(format!("{file}: disk full")), &file);
|
||||
assert_eq!(io, "doc.svg: disk full");
|
||||
let bad = named(EngineError::BadParams { cmd: "x".into(), msg: format!("{file}: not a file") }, &file);
|
||||
assert_eq!(bad, "invalid parameters for `x`: doc.svg: not a file");
|
||||
// A save refused by the file system keeps its type and its code.
|
||||
let read_only = confine(roots.as_ref(), || naming_in_error(EngineError::ReadOnlyLocation(format!("{file}: Permission denied")), &file));
|
||||
assert!(matches!(&read_only, EngineError::ReadOnlyLocation(_)));
|
||||
assert_eq!(read_only.to_string(), format!("{}: doc.svg: Permission denied", crate::READ_ONLY_LOCATION));
|
||||
// A Windows path written with `\`, a path that ends in no name and a name that isn't text.
|
||||
let win = r"C:\Users\me\secret\doc.svg";
|
||||
assert_eq!(named(EngineError::Other(format!("{win}: denied")), win), "doc.svg: denied");
|
||||
let folder = format!("{}/", text(&out));
|
||||
assert_eq!(named(EngineError::Other(format!("{folder}: denied")), &folder), format!("{UNNAMED}: denied"));
|
||||
let lossy = text(&out.join("bad\u{FFFD}.svg"));
|
||||
assert_eq!(named(EngineError::Other(format!("{lossy}: denied")), &lossy), format!("{UNNAMED}: denied"));
|
||||
// Readable, or nothing confined: whole.
|
||||
let inside = text(&a.join("x.svg"));
|
||||
assert_eq!(named(EngineError::Other(format!("{inside}: denied")), &inside), format!("{inside}: denied"));
|
||||
assert_eq!(naming_in_error(EngineError::Other(raw.clone()), &file).to_string(), raw);
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1429,6 +1429,42 @@ mod tests {
|
||||
assert_eq!(written.len(), 2, "{written:?}");
|
||||
}
|
||||
|
||||
/// A Data Sets batch whose Save step has no path saves the document's own file. When that file is
|
||||
/// outside the client's read and write roots, each row's error keeps the write root and names the
|
||||
/// file by its file name, with Stop on Error on and off. A source folder the client gives stays
|
||||
/// whole in the error.
|
||||
#[test]
|
||||
fn dataset_batch_step_error_hides_document_path() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let base = tmp("roots-error");
|
||||
let (inside, outside) = (base.join("in"), base.join("kept-out"));
|
||||
for f in [&inside, &outside] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let mut s = with_data_sets(&["Alpha", "Beta"]);
|
||||
let own = outside.join("doc.vectorcraft").to_string_lossy().into_owned();
|
||||
s.execute("document.save", &json!({"path": own})).unwrap();
|
||||
let roots = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap();
|
||||
let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&inside).unwrap()).display().to_string();
|
||||
let hidden = outside.to_string_lossy().into_owned();
|
||||
for (stop, rows) in [(true, 1), (false, 2)] {
|
||||
let p = json!({"source": "datasets", "stopOnError": stop, "commands": [{"command": "document.save", "params": {}}]});
|
||||
let r = confine(roots.as_ref(), || s.execute("action.batch", &p)).unwrap();
|
||||
assert_eq!((r["failed"].as_u64(), r["processed"].as_array().map(Vec::len)), (Some(rows), Some(rows as usize)), "{r}");
|
||||
for row in r["processed"].as_array().unwrap() {
|
||||
let e = row["error"].as_str().unwrap_or_default();
|
||||
let want = format!("batch step 0 (`document.save`) failed: automation path rejected: outside the write root {root}: doc.vectorcraft");
|
||||
assert_eq!(e, want, "{r}");
|
||||
}
|
||||
assert!(!r.to_string().contains(&hidden), "{r}");
|
||||
}
|
||||
let p = json!({"source": "folder", "folder": hidden, "commands": [{"command": "object.unlockAll", "params": {}}]});
|
||||
let e = confine(roots.as_ref(), || s.execute("action.batch", &p)).unwrap_err().to_string();
|
||||
assert!(e.contains(&hidden), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// The names of the files in `dir`, sorted.
|
||||
fn file_names(dir: &std::path::Path) -> Vec<String> {
|
||||
let mut names: Vec<String> =
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -398,7 +398,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data)));
|
||||
// Relative links are found in the SVG's folder (when `name` is a path).
|
||||
let folder = std::path::Path::new(name).parent().map(|f| f.to_string_lossy()).filter(|f| !f.is_empty()).map(|f| absolute_path(&f));
|
||||
restore_or_import(editing, || import_svg(&text, folder.as_deref()))?
|
||||
restore_or_import(editing, || open_svg(&text, folder.as_deref()))?
|
||||
}
|
||||
"pdf" | "ai" | "ait" => {
|
||||
// The file is read and checked once, for the document it carries, its pages, the
|
||||
@@ -573,6 +573,33 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
/// own): rasters stay linked, SVG files become art, missing ones placeholders (see
|
||||
/// [`vectorcraft_svg::ImportOptions`]).
|
||||
pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec<String>)> {
|
||||
import_svg_finding(text, folder, &std::collections::HashSet::new()).map(|(doc, warnings, _)| (doc, warnings))
|
||||
}
|
||||
|
||||
/// [`import_svg`] for a document opened from an SVG: with the variable data the file carries (SVG
|
||||
/// Options › Include Variable Data), read as [`crate::cmd::variablecmds::svg_library`] says. Variable
|
||||
/// data that can't be read is left out with a warning; the art opens either way.
|
||||
fn open_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec<String>)> {
|
||||
let lib = match crate::cmd::variablecmds::svg_library(text) {
|
||||
None => return import_svg(text, folder),
|
||||
Some(Err(e)) => {
|
||||
let (doc, mut warnings) = import_svg(text, folder)?;
|
||||
warnings.push(format!("the file's variable data wasn't read: {e}"));
|
||||
return Ok((doc, warnings));
|
||||
}
|
||||
Some(Ok(lib)) => lib,
|
||||
};
|
||||
let (mut doc, mut warnings, found) = import_svg_finding(text, folder, &lib.elements())?;
|
||||
warnings.extend(crate::cmd::variablecmds::give_svg_library(&mut doc, lib, &found));
|
||||
Ok((doc, warnings))
|
||||
}
|
||||
|
||||
/// [`import_svg`], also returning the object made of each element whose id `ids` lists.
|
||||
fn import_svg_finding(
|
||||
text: &str,
|
||||
folder: Option<&str>,
|
||||
ids: &std::collections::HashSet<String>,
|
||||
) -> Result<(Document, Vec<String>, vectorcraft_svg::Found)> {
|
||||
// The importer reads on a thread of its own (for its stack): what may be read is what may be
|
||||
// read here.
|
||||
let scope = crate::file_access::ScopeHandle::capture();
|
||||
@@ -588,7 +615,7 @@ pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document,
|
||||
Some((bytes, link))
|
||||
})
|
||||
};
|
||||
vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err)
|
||||
vectorcraft_svg::import_finding(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }, ids).map_err(err)
|
||||
}
|
||||
|
||||
/// Open a file's bytes as the new active document (what `document.open` does) →
|
||||
@@ -1019,13 +1046,38 @@ fn is_loss(note: &str) -> bool {
|
||||
/// Another name keeps the original file, with all it has.
|
||||
pub(crate) fn check_not_lossy_overwrite(st: &crate::DocState, target: &str, p: &Value, cmd: &str) -> Result<()> {
|
||||
let Some(losses) = overwrite_losses(st, target, p) else { return Ok(()) };
|
||||
Err(lossy_overwrite(target, losses, cmd))
|
||||
}
|
||||
|
||||
/// The refusal to write `target`, the file the document was read from, when reading it left out `losses`.
|
||||
fn lossy_overwrite(target: &str, losses: &[String], cmd: &str) -> EngineError {
|
||||
let what = losses_summary(losses);
|
||||
Err(bad(
|
||||
bad(
|
||||
cmd,
|
||||
format!(
|
||||
"{target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway"
|
||||
),
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
/// The file the document was read from and what reading it left out, when a write to that file
|
||||
/// needs `acknowledgeLoss` and `p` does not have it ([`overwrite_losses`]).
|
||||
pub(crate) fn lossy_source(st: &crate::DocState, p: &Value) -> Option<(String, Vec<String>)> {
|
||||
let source = st.imported_from.as_deref().filter(|_| !st.import_losses.is_empty())?;
|
||||
(!bool_or(p, "acknowledgeLoss", false)).then(|| (source.to_string(), st.import_losses.clone()))
|
||||
}
|
||||
|
||||
/// None of the files `targets` (every file a save writes, before any is written) is the
|
||||
/// `source` file that reading left `losses` out of ([`lossy_source`]).
|
||||
pub(crate) fn check_files_not_lossy_overwrite<'a>(
|
||||
source: &(String, Vec<String>),
|
||||
targets: impl IntoIterator<Item = &'a str>,
|
||||
cmd: &str,
|
||||
) -> Result<()> {
|
||||
match targets.into_iter().find(|t| same_file(&source.0, t)) {
|
||||
Some(target) => Err(lossy_overwrite(target, &source.1, cmd)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What writing the file `target` for document `st` would lose for good: the notes of what reading
|
||||
|
||||
@@ -99,6 +99,10 @@ pub struct SavePlan {
|
||||
/// `preview`, `precision`) given beside the path, and each key of `options`, that the format
|
||||
/// doesn't read, naming the file the save writes.
|
||||
pub notes: Vec<String>,
|
||||
/// The file the document was read from and what reading it left out, when the save needs
|
||||
/// `acknowledgeLoss` to write over it and was not given it: every file the save writes is
|
||||
/// checked against it before the first is written.
|
||||
pub lossy_source: Option<(String, Vec<String>)>,
|
||||
}
|
||||
|
||||
impl SavePlan {
|
||||
@@ -303,7 +307,9 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
};
|
||||
let modified = date_param(p, "modified", cmd)?;
|
||||
if let Some(path) = &path {
|
||||
super::check_not_lossy_overwrite(st, path, p, cmd)?;
|
||||
// Without a path, it is the document's own file: the error names it as the request may see it.
|
||||
let given = str_param(p, "path").is_some_and(|g| !g.is_empty());
|
||||
super::check_not_lossy_overwrite(st, path, p, cmd).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })?;
|
||||
}
|
||||
let written = written_as(cmd, format, &Value::Object(options.clone()));
|
||||
let top = NATIVE_OPTIONS.iter().filter(|k| p.get(**k).is_some_and(|v| !v.is_null())).map(|k| k.to_string());
|
||||
@@ -313,7 +319,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
.chain(nested.filter(|k| !reads_option(format, k)).map(|k| format!("options.{k}")))
|
||||
.map(|k| format!("`{k}` was not used: {} files don't read it", written.label))
|
||||
.collect();
|
||||
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes })
|
||||
let lossy_source = super::lossy_source(st, p);
|
||||
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source })
|
||||
}
|
||||
|
||||
/// The native save options, which a save also takes beside the path.
|
||||
@@ -910,6 +917,9 @@ impl SaveJob {
|
||||
return Ok(out);
|
||||
};
|
||||
let files = enc.named(doc, path);
|
||||
if let Some(source) = &plan.lossy_source {
|
||||
super::check_files_not_lossy_overwrite(source, files.iter().map(|(p, _)| p.as_str()), plan.mode.command())?;
|
||||
}
|
||||
if let Some(stopped) = stop(&files.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>()) {
|
||||
return Ok(stopped);
|
||||
}
|
||||
@@ -1048,7 +1058,12 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value>
|
||||
let plan = save_plan(s, mode, p)?;
|
||||
let job = save_job(s, plan)?;
|
||||
let uid = s.doc()?.uid;
|
||||
let mut out = job.write_streamed(&mut super::write_save_file_with)?;
|
||||
let given = str_param(p, "path").is_some_and(|g| !g.is_empty());
|
||||
// Without a path, the files written are the document's own file and those beside it.
|
||||
let mut write = |path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| {
|
||||
super::write_save_file_with(path, fill).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })
|
||||
};
|
||||
let mut out = job.write_streamed(&mut write)?;
|
||||
job.complete(s, uid);
|
||||
leave_out_unreadable_save_paths(&mut out, str_param(p, "path"));
|
||||
Ok(out)
|
||||
@@ -1103,7 +1118,8 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> {
|
||||
fn revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let index = s.active_index().ok_or(crate::EngineError::NoDocument)?;
|
||||
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
|
||||
let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs);
|
||||
let bytes = read_file(&path).map_err(|e| super::access::naming_in_error(e, &path))?;
|
||||
let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &bytes)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs);
|
||||
doc.template = false;
|
||||
// Back to the saved file: its recovery copy has nothing left to recover.
|
||||
let uid = s.doc()?.uid;
|
||||
|
||||
@@ -649,14 +649,14 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = with_preset(s, p)?;
|
||||
let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) };
|
||||
let mut r = export(s, &p).map_err(|e| match hidden {
|
||||
Some(folder) => leave_out_folder_from_error(e, folder),
|
||||
Some(folder) => super::access::naming_in_error(e, folder),
|
||||
None => e,
|
||||
})?;
|
||||
remember(s, &p);
|
||||
// A prepared export is written later, maybe on another thread: it names the folder as this
|
||||
// request may see it.
|
||||
if let (Some(folder), Some(Some(export))) = (hidden, s.screens_hand_off.as_mut()) {
|
||||
export.hidden = Some((folder.to_string(), super::access::shown_path(folder).into_owned()));
|
||||
export.hidden = Some((folder.to_string(), super::access::error_name(folder)));
|
||||
}
|
||||
if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) {
|
||||
leave_out_unreadable_folder(&mut r, folder);
|
||||
@@ -664,45 +664,6 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// An error of an export into `folder`, a folder outside the read roots that the client did not
|
||||
/// give: `folder` as the path the error is about names it by its own name
|
||||
/// ([`super::access::shown_path`]), and the rest of the message stays.
|
||||
fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError {
|
||||
leave_out_folder_named(e, folder, &super::access::shown_path(folder))
|
||||
}
|
||||
|
||||
/// [`leave_out_folder_from_error`] with `folder` named `name`.
|
||||
fn leave_out_folder_named(e: EngineError, folder: &str, name: &str) -> EngineError {
|
||||
match e {
|
||||
EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, name)),
|
||||
EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, name) },
|
||||
e => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start
|
||||
/// of `msg` (`<path>: <reason>`), after a `: ` (`... outside the write root <root>: <path>`) or
|
||||
/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message
|
||||
/// names stays whole, even one inside `folder`.
|
||||
fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String {
|
||||
const BEFORE: [&str; 2] = [": ", "cannot resolve "];
|
||||
let mut out = String::with_capacity(msg.len());
|
||||
let mut rest = msg;
|
||||
loop {
|
||||
if let Some(after) = rest.strip_prefix(folder)
|
||||
&& after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' '))
|
||||
{
|
||||
out.push_str(name);
|
||||
rest = after;
|
||||
}
|
||||
let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break };
|
||||
out.push_str(&rest[..i + len]);
|
||||
rest = &rest[i + len..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The reply `r` of an export into `folder`, a folder the client did not give, as the request being
|
||||
/// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's
|
||||
/// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`),
|
||||
@@ -1162,7 +1123,7 @@ impl ScreenExport {
|
||||
let r = encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) });
|
||||
match hidden {
|
||||
Some((folder, name)) => {
|
||||
let mut r = r.map_err(|e| leave_out_folder_named(e, &folder, &name))?;
|
||||
let mut r = r.map_err(|e| super::access::naming_as(e, &folder, &name))?;
|
||||
leave_out_folder(&mut r, &folder);
|
||||
Ok(r)
|
||||
}
|
||||
@@ -1516,7 +1477,8 @@ pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name};
|
||||
use super::super::access::naming_in_message;
|
||||
use super::{Output, ScreenSize, safe_file_name};
|
||||
|
||||
/// A hidden export folder is named by its name where a message is about it, and a root that
|
||||
/// starts with the same text, or a longer path, stays whole.
|
||||
@@ -1533,7 +1495,7 @@ mod tests {
|
||||
("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"),
|
||||
];
|
||||
for (msg, want) in cases {
|
||||
assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}");
|
||||
assert_eq!(naming_in_message(msg, f, "out"), want, "{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,12 @@ pub const OPTIONS: &[FormatOption] = &[
|
||||
default: "false",
|
||||
description: "embed the fonts type uses as @font-face, subset to the characters used (whole when the font's licence forbids subsetting, left out with a warning when it forbids embedding)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "variableData",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "Include Variable Data: write the text variables, the data sets and the type object each text variable is bound to as a variable library (variableSets) that document.open and variable.loadLibrary read back; variables of other kinds, and what is past the limits variable.loadLibrary reads (256 text variables, 1,000 data sets, 8 MiB), are left out with a warning",
|
||||
},
|
||||
FormatOption {
|
||||
name: "svg",
|
||||
ty: "object",
|
||||
|
||||
@@ -43,12 +43,62 @@ impl Visitor for Blocked {
|
||||
}
|
||||
}
|
||||
|
||||
/// A test's folder tree, removed with everything in it when dropped, also when the test panics,
|
||||
/// and also when the test locked a folder in it. A removal that fails (a walker still holding a
|
||||
/// file open) is ignored.
|
||||
struct Tree(PathBuf);
|
||||
|
||||
impl std::ops::Deref for Tree {
|
||||
type Target = PathBuf;
|
||||
fn deref(&self) -> &PathBuf {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tree {
|
||||
fn drop(&mut self) {
|
||||
if std::fs::remove_dir_all(&self.0).is_err() {
|
||||
unlock(&self.0);
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives the owner read, write and search permission on `folder` and on every folder in it, so
|
||||
/// that a tree holding a folder a test locked can be removed. Links are not followed.
|
||||
#[cfg(unix)]
|
||||
fn unlock(folder: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let Ok(meta) = std::fs::symlink_metadata(folder) else { return };
|
||||
if !meta.is_dir() {
|
||||
return;
|
||||
}
|
||||
let mode = meta.permissions().mode() & 0o7777;
|
||||
if mode & 0o700 != 0o700 {
|
||||
let _ = std::fs::set_permissions(folder, std::fs::Permissions::from_mode(mode | 0o700));
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(folder) else { return };
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().is_ok_and(|t| t.is_dir()) {
|
||||
unlock(&entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Folders on other systems are removed as they are.
|
||||
#[cfg(not(unix))]
|
||||
fn unlock(_: &Path) {}
|
||||
|
||||
/// A fresh folder (canonical, as a search reports it) holding `paths`: files, and folders where
|
||||
/// they end in `/`.
|
||||
fn tree(tag: &str, paths: &[&str]) -> PathBuf {
|
||||
let root = vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
/// they end in `/`. The folder is removed when the returned [`Tree`] is dropped.
|
||||
fn tree(tag: &str, paths: &[&str]) -> Tree {
|
||||
let mut root = Tree(vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}")));
|
||||
// Empties what a tree whose removal failed left, keeping the folder as `temp_dir` made it
|
||||
// (owner-only on Unix).
|
||||
for entry in std::fs::read_dir(&root.0).unwrap().flatten() {
|
||||
let path = entry.path();
|
||||
let _ = if entry.file_type().is_ok_and(|t| t.is_dir()) { std::fs::remove_dir_all(&path) } else { std::fs::remove_file(&path) };
|
||||
}
|
||||
for p in paths {
|
||||
let path = root.join(p);
|
||||
if p.ends_with('/') {
|
||||
@@ -58,7 +108,54 @@ fn tree(tag: &str, paths: &[&str]) -> PathBuf {
|
||||
std::fs::write(&path, b"font").unwrap();
|
||||
}
|
||||
}
|
||||
plain(std::fs::canonicalize(&root).unwrap())
|
||||
root.0 = plain(std::fs::canonicalize(&root.0).unwrap());
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tree_is_removed_when_its_test_ends() {
|
||||
let path = {
|
||||
let root = tree("removed", &["a/b/one.ttf", "c/"]);
|
||||
assert!(root.join("a/b/one.ttf").is_file() && root.join("c").is_dir());
|
||||
root.to_path_buf()
|
||||
};
|
||||
assert!(!path.exists(), "{} is left", path.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tree_is_removed_when_its_test_panics() {
|
||||
let mut path = PathBuf::new();
|
||||
let r = vectorcraft_testkit::catch_quiet::<()>(|| {
|
||||
let root = tree("unwound", &["a/b/one.ttf"]);
|
||||
path = root.to_path_buf();
|
||||
panic!("a failed assertion");
|
||||
});
|
||||
assert_eq!(r.err().as_deref(), Some("a failed assertion"));
|
||||
assert!(!path.as_os_str().is_empty() && !path.exists(), "{} is left", path.display());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_tree_is_private_to_this_user() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let root = tree("private", &["a/one.ttf"]);
|
||||
let mode = std::fs::metadata(&*root).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o077, 0, "{}: mode {mode:o}", root.display());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_tree_with_a_locked_folder_is_removed_when_its_test_panics() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut path = PathBuf::new();
|
||||
let r = vectorcraft_testkit::catch_quiet::<()>(|| {
|
||||
let root = tree("locked", &["a/locked/b/one.ttf"]);
|
||||
path = root.to_path_buf();
|
||||
std::fs::set_permissions(root.join("a/locked"), std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
panic!("a failed assertion");
|
||||
});
|
||||
assert_eq!(r.err().as_deref(), Some("a failed assertion"));
|
||||
assert!(!path.as_os_str().is_empty() && !path.exists(), "{} is left", path.display());
|
||||
}
|
||||
|
||||
/// The rules of a search of `root`: its `Users/me` is the home folder, `refused` an app's folder.
|
||||
|
||||
@@ -850,14 +850,23 @@ fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn rect_from(p: &Value, default: Rect) -> Rect {
|
||||
/// The rectangle from `x`, `y`, `width` and `height`, each defaulting to `default`'s. Sides are kept as given.
|
||||
fn rect_unclamped(p: &Value, default: Rect) -> Rect {
|
||||
let x = f64_or(p, "x", default.x0);
|
||||
let y = f64_or(p, "y", default.y0);
|
||||
let w = f64_or(p, "width", default.width()).max(1.0);
|
||||
let h = f64_or(p, "height", default.height()).max(1.0);
|
||||
let w = f64_or(p, "width", default.width());
|
||||
let h = f64_or(p, "height", default.height());
|
||||
Rect::new(x, y, x + w, y + h)
|
||||
}
|
||||
|
||||
/// [`rect_unclamped`] with each supplied side raised to at least 1 pt, as `artboard.setProps` uses it.
|
||||
/// A side left out keeps `default`'s, even below 1 pt.
|
||||
fn rect_from(p: &Value, default: Rect) -> Rect {
|
||||
let r = rect_unclamped(p, default);
|
||||
let floor = |key: &str, side: f64| if p.get(key).and_then(Value::as_f64).is_some() { side.max(1.0) } else { side };
|
||||
Rect::new(r.x0, r.y0, r.x0 + floor("width", r.width()), r.y0 + floor("height", r.height()))
|
||||
}
|
||||
|
||||
/// An artboard command's `color` param, its background color: None when absent, `Some(None)` for
|
||||
/// null (no color), else `Some(Some(color))`.
|
||||
fn artboard_color(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_color::Color>>> {
|
||||
@@ -942,7 +951,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
src + side.offset(src)
|
||||
}
|
||||
None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")),
|
||||
None => rect_from(p, def),
|
||||
None => rect_unclamped(p, def),
|
||||
};
|
||||
// A size taken from another artboard (`side`, or width or height left out) keeps to the limit too.
|
||||
if !super::newdoc::side_in_range(r.width()) || !super::newdoc::side_in_range(r.height()) {
|
||||
|
||||
@@ -28,7 +28,9 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
|
||||
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect, shapes};
|
||||
|
||||
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
|
||||
@@ -68,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Relink",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
|
||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots)}",
|
||||
has_doc,
|
||||
relink
|
||||
),
|
||||
@@ -1197,6 +1199,78 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `d`, a document saved at `doc_path`, as a reply to the caller asking now may show it: without
|
||||
/// read roots in force, `d` itself; with them, a copy in which each link [`path_shown`] does not
|
||||
/// allow names its file alone, without its relative path or the size and date kept of its file,
|
||||
/// and each data set's image value it does not allow names its file alone too. The folder Export
|
||||
/// for Screens last wrote to is left out of the export settings when it is outside the read roots.
|
||||
/// A link whose path is allowed but whose relative path leads outside the read roots loses its
|
||||
/// relative path. Links in the layers, symbols, graph designs, patterns, the pattern being edited
|
||||
/// as it was before, opacity masks and the art of the stored brushes are all checked. A stored
|
||||
/// brush entry that does not read as a brush is left out of the copy, since its links cannot be
|
||||
/// checked, and a stored brush library that is not a list becomes an empty one.
|
||||
pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Cow<'a, Document> {
|
||||
if crate::file_access::current().is_none() {
|
||||
return Cow::Borrowed(d);
|
||||
}
|
||||
let doc_path = base.doc;
|
||||
// A stored relative path is followed from the folder of the file the document was read from,
|
||||
// and a link is also looked for from the document's own folder: it is shown only when both
|
||||
// places it names are inside the read roots.
|
||||
let relative_hidden = |r: &str| [base.relative, base.doc].into_iter().any(|from| !path_shown(r, from));
|
||||
let mut d = d.clone();
|
||||
// The pattern being edited as it was before is checked as one more pattern, then put back.
|
||||
let original = d.pattern_edit.as_mut().and_then(|e| e.original.take());
|
||||
let edited = original.is_some();
|
||||
d.patterns.extend(original);
|
||||
// The art of each stored brush is checked as more layers, then put back.
|
||||
let stored = d.unknown.get(vectorcraft_brush::DOC_KEY).map(|v| v.as_array().cloned().unwrap_or_default());
|
||||
let mut brushes: Vec<vectorcraft_brush::Brush> =
|
||||
stored.iter().flatten().filter_map(|raw| vectorcraft_brush::Brush::from_json(raw.clone()).ok()).collect();
|
||||
let layers = d.layers.len();
|
||||
d.layers.extend(brushes.iter().flat_map(|b| b.art()).map(|n| Arc::new(n.clone())));
|
||||
d.update_links_where(
|
||||
|l| !path_shown(&l.path, base.doc) || l.relative.as_deref().is_some_and(relative_hidden),
|
||||
|l| {
|
||||
if path_shown(&l.path, base.doc) {
|
||||
l.relative = None;
|
||||
} else {
|
||||
let name = l.name().to_string();
|
||||
*l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() };
|
||||
}
|
||||
},
|
||||
);
|
||||
let mut checked = d.layers.split_off(layers).into_iter();
|
||||
if edited && let (Some(def), Some(e)) = (d.patterns.pop(), d.pattern_edit.as_mut()) {
|
||||
e.original = Some(def);
|
||||
}
|
||||
if stored.is_some() {
|
||||
// Each brush is written as it reads, so fields it does not read are left out too.
|
||||
let mut kept = vec![];
|
||||
for b in &mut brushes {
|
||||
for (slot, art) in b.art_mut().into_iter().zip(&mut checked) {
|
||||
*slot = Arc::unwrap_or_clone(art);
|
||||
}
|
||||
kept.extend(serde_json::to_value(&*b).ok());
|
||||
}
|
||||
d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(kept));
|
||||
}
|
||||
if d.export_settings.get("folder").and_then(Value::as_str).is_some_and(|f| crate::file_access::check_read(f).is_err()) {
|
||||
d.export_settings.remove("folder");
|
||||
}
|
||||
let hidden = |v: &DataValue| matches!(v, DataValue::Image(path) if !path_shown(path, doc_path));
|
||||
if d.variables.datasets.iter().any(|set| set.values.values().any(hidden)) {
|
||||
for v in d.variables.datasets.iter_mut().flat_map(|set| set.values.values_mut()) {
|
||||
if hidden(&*v)
|
||||
&& let DataValue::Image(path) = v
|
||||
{
|
||||
*path = LinkInfo::new(path.as_str()).name().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
Cow::Owned(d)
|
||||
}
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1339,7 +1413,9 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(true, false) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")),
|
||||
(true, true) => {
|
||||
let doc = st.path.as_deref().map(absolute_path).ok_or_else(|| bad(C, "save the document first: copy puts the file in its folder"))?;
|
||||
Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(|| bad(C, format!("{doc} has no folder")))?)
|
||||
// The document's path is not the client's: the error names it as the request may see it.
|
||||
let no_folder = || fileio::naming_in_error(bad(C, format!("{doc} has no folder")), &doc);
|
||||
Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(no_folder)?)
|
||||
}
|
||||
};
|
||||
// An image or a placed document, with its link.
|
||||
@@ -1474,6 +1550,12 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
not_found.dedup();
|
||||
let mut out = json!({ "relinked": relinked, "notFound": not_found });
|
||||
if copy_to.is_some() {
|
||||
// The copies are in the document's folder, which the client did not give.
|
||||
for pair in copied.iter_mut().chain(kept.iter_mut()) {
|
||||
if let Some(to) = pair.get("to").and_then(Value::as_str).map(str::to_string) {
|
||||
pair["to"] = json!(fileio::shown_path(&to));
|
||||
}
|
||||
}
|
||||
out["copied"] = json!(copied);
|
||||
out["kept"] = json!(kept);
|
||||
}
|
||||
@@ -1523,9 +1605,14 @@ fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, boo
|
||||
use std::io::Write as _;
|
||||
let source = Path::new(from);
|
||||
let name = source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
// Each name it takes is checked against the automation roots in force.
|
||||
// Each name it takes is checked against the automation roots in force. The folder is the
|
||||
// document's, so a refusal names it and the file as the request may see them.
|
||||
let create = |to: &Path| {
|
||||
crate::file_access::check_write(&to.to_string_lossy()).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
let to_text = to.to_string_lossy();
|
||||
crate::file_access::check_write(&to_text).map_err(|e| {
|
||||
let e = fileio::naming_in_error(EngineError::Other(e), &to_text);
|
||||
std::io::Error::new(std::io::ErrorKind::PermissionDenied, e.to_string())
|
||||
})?;
|
||||
vectorcraft_format::write_new_with(to, |f| f.write_all(bytes))
|
||||
};
|
||||
// A file there that automation may not read isn't taken for the same file.
|
||||
@@ -1536,7 +1623,10 @@ fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, boo
|
||||
Ok(Some(fileio::Placed::Copied(to))) => Ok((to.to_string_lossy().into_owned(), true)),
|
||||
Ok(Some(fileio::Placed::Kept(to))) => Ok((to.to_string_lossy().into_owned(), false)),
|
||||
Ok(None) => Err(EngineError::Other(format!("every numbered name for {name} is taken in the document's folder"))),
|
||||
Err(e) => Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
||||
Err(e) => {
|
||||
let dir = dir.to_string_lossy();
|
||||
Err(fileio::naming_in_error(EngineError::Other(format!("{dir}: {e}")), &dir))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -389,6 +389,39 @@ pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn blend_set_easing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.setEasing";
|
||||
let id = match crate::cmd::object_param(p, "id", C)? {
|
||||
Some(id) => id,
|
||||
None => *selected_of(s, is_blend).first().ok_or_else(|| bad(C, "select a blend or give `id`"))?,
|
||||
};
|
||||
let doc = &s.doc()?.doc;
|
||||
let Some(NodeKind::Blend { children, spec }) = doc.node(id).map(|n| &n.kind) else { return Err(bad(C, "not a blend")) };
|
||||
if !doc.is_editable(id) {
|
||||
return Err(bad(C, "the object is hidden or locked"));
|
||||
}
|
||||
let ease = ease_param(p, C, spec.ease)?;
|
||||
let color_shift = percent_param(p, C, "colorShift", spec.color_shift, -100.0)?;
|
||||
// Easing moves the steps along a spine, so it needs one with a length to run along.
|
||||
let easing = ["easingType", "ramp"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null()));
|
||||
let spined = spec.eases_along_spine()
|
||||
&& live::blend_spine(children, spec).and_then(|(path, _)| Spine::new(&path)).is_some_and(|sp| sp.length().is_finite() && sp.length() > 0.0);
|
||||
if easing && !spined {
|
||||
return Err(bad(C, "the blend has no spine"));
|
||||
}
|
||||
let changed = ease != spec.ease || color_shift != spec.color_shift;
|
||||
if changed {
|
||||
s.edit("Blend Options", |d, _| {
|
||||
if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
spec.ease = ease;
|
||||
spec.color_shift = color_shift;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "id": id.0, "easingType": ease.kind.id(), "ramp": ease.ramp * 100.0, "colorShift": color_shift, "changed": changed }))
|
||||
}
|
||||
|
||||
/// Spacing and orientation as `object.blend.options` takes them; the step count and distance
|
||||
/// not in use show their defaults.
|
||||
fn blend_options_json(spacing: BlendSpacing, orientation: BlendOrientation) -> Value {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::blend::{
|
||||
blend_create_spine, blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine,
|
||||
blend_reverse_stack, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment,
|
||||
blend_reverse_stack, blend_set_easing, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment,
|
||||
};
|
||||
use super::common::{has_blend, has_envelope, has_mesh};
|
||||
use super::envelope::{
|
||||
@@ -49,6 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
blend_create_spine
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.setEasing",
|
||||
"Blend Easing",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the first selected blend), easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100)} set one blend's easing and color shift, as the blend widgets on the canvas do; omitted ones keep their value. easingType and ramp need a blend whose steps follow a spine (a replaced or edited spine, or Align to Path); a blend made without a spine takes them once Replace Spine has given it one; out of range, a hidden or locked blend and a blend with no spine are errors. A value that changes nothing records no undo step → {id, easingType, ramp, colorShift, changed}",
|
||||
has_doc,
|
||||
blend_set_easing
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.expand",
|
||||
"Expand",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -896,7 +896,7 @@ pub(crate) fn outlined_stroke(
|
||||
};
|
||||
let art = d.reid(&art);
|
||||
let mut art = with_transparency(d, art, st.opacity, st.blend);
|
||||
let raster: Vec<_> = st.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect();
|
||||
let raster = vectorcraft_render::effects::without_geometry(&st.effects);
|
||||
if !raster.is_empty() {
|
||||
if !art.appearance.effects.is_empty() {
|
||||
art = Node::group(d.alloc_id(), vec![Arc::new(art)]);
|
||||
@@ -946,7 +946,7 @@ pub(crate) fn outlined_stroke(
|
||||
/// geometry effects leave nothing to paint, an empty group takes the path's place.
|
||||
/// `None` when `l` has no stroke to outline.
|
||||
pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option<Node> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, is_geometry, reshape};
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, reshape, without_geometry};
|
||||
if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) {
|
||||
return None;
|
||||
}
|
||||
@@ -975,7 +975,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
|
||||
}
|
||||
let mut plain = moved;
|
||||
plain.effects_mut().retain(|e| !is_geometry(&e.id));
|
||||
*plain.effects_mut() = without_geometry(plain.effects());
|
||||
parts.push(styled_copy(d, &shape, vec![plain]));
|
||||
}
|
||||
AppearanceItem::Stroke(st) if outlinable(st) => {
|
||||
@@ -983,7 +983,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
|
||||
}
|
||||
let mut plain = st.clone();
|
||||
plain.effects.retain(|e| !is_geometry(&e.id));
|
||||
plain.effects = without_geometry(&st.effects);
|
||||
let stroked = match path.bounds() {
|
||||
Some(b) if has_geometry(&st.effects) => apply_geometry_with(&st.effects, &path, b, &ctx.item(item)),
|
||||
_ => path.clone(),
|
||||
|
||||
@@ -245,6 +245,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"object.blend.replaceSpine",
|
||||
"object.blend.reverseFrontToBack",
|
||||
"object.blend.reverseSpine",
|
||||
"object.blend.setEasing",
|
||||
"object.blend.spine.addAnchor",
|
||||
"object.blend.spine.convertAnchor",
|
||||
"object.blend.spine.moveAnchor",
|
||||
|
||||
@@ -156,6 +156,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool),
|
||||
p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool),
|
||||
p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool),
|
||||
p!("boundingBoxWhileMovingAndDrawing", "General", "Options", "Show Bounding Box While Moving and Drawing", bool),
|
||||
// Selection & Anchor Display
|
||||
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
|
||||
p!(
|
||||
|
||||
@@ -1163,6 +1163,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
|
||||
folder: None,
|
||||
modified: None,
|
||||
notes: Vec::new(),
|
||||
lossy_source: None,
|
||||
};
|
||||
let save = fileio::job_for(s, st, plan)?;
|
||||
let meta = Meta {
|
||||
@@ -1275,15 +1276,16 @@ enum Owner {
|
||||
/// dropped) and, with `take` in a store without locks, taken over with a fresh heartbeat, so
|
||||
/// another app leaves it alone meanwhile. In a store without locks an area is gone only when
|
||||
/// nothing says its app runs: no announcement (or the store can't tell) and no heartbeat for three
|
||||
/// intervals but the one this session wrote taking it over.
|
||||
fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<Hold>, take: bool) -> Owner {
|
||||
/// intervals but the one this session wrote taking it over. `held` names the gone apps' areas the
|
||||
/// caller already holds.
|
||||
fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<Hold>, take: bool, held: &[&str]) -> Owner {
|
||||
if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) {
|
||||
return Owner::Mine;
|
||||
}
|
||||
match store.lock(area) {
|
||||
Ok(Lock::Held(h)) => {
|
||||
// Copies an app left here when it had to leave the area stay its own while it runs.
|
||||
if let Some(who) = keeper(s, store, area) {
|
||||
if let Some(who) = keeper(s, store, area, held) {
|
||||
return who;
|
||||
}
|
||||
hold.push(h);
|
||||
@@ -1294,7 +1296,7 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<
|
||||
if store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()) {
|
||||
return Owner::Running;
|
||||
}
|
||||
if let Some(who) = keeper(s, store, area) {
|
||||
if let Some(who) = keeper(s, store, area, held) {
|
||||
return who;
|
||||
}
|
||||
// Best effort: without it another app might offer the copies too, which loses nothing.
|
||||
@@ -1310,12 +1312,14 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<
|
||||
/// `<its area>/keeps-<area>` ([`mark_left`]): this session, or another app that is running. `None`:
|
||||
/// no app that runs keeps them. An entry counts only in an area this account can lock or tell is
|
||||
/// held ([`holder`]), so an entry in a folder of another account, or in one whose lock can't be
|
||||
/// checked, keeps nothing. A store that can't be listed now keeps them (a copy kept loses nothing).
|
||||
fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str) -> Option<Owner> {
|
||||
/// checked, keeps nothing. So does an entry in one of the gone apps' areas the caller holds
|
||||
/// (`held`), whose lock this session would otherwise find taken. A store that can't be listed now
|
||||
/// keeps them (a copy kept loses nothing).
|
||||
fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str, held: &[&str]) -> Option<Owner> {
|
||||
let Ok(names) = store.list() else { return Some(Owner::Running) };
|
||||
let mark = format!("/{KEEPS}{area}");
|
||||
let mut who = None;
|
||||
for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area) {
|
||||
for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area && !held.contains(k)) {
|
||||
match holder(s, store, k) {
|
||||
Owner::Mine => return Some(Owner::Mine),
|
||||
Owner::Running => who = Some(Owner::Running),
|
||||
@@ -1377,7 +1381,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut rows = vec![];
|
||||
for area in store.areas().map_err(EngineError::Other)? {
|
||||
let mut hold = vec![];
|
||||
let who = owner(s, store.as_ref(), &area, &mut hold, false);
|
||||
let who = owner(s, store.as_ref(), &area, &mut hold, false, &[]);
|
||||
let files: Vec<&String> = copies.iter().filter(|(a, _)| *a == area).map(|(_, f)| f).collect();
|
||||
for f in &files {
|
||||
let meta = read_meta(store.as_ref(), f);
|
||||
@@ -1417,10 +1421,11 @@ fn targets(s: &mut Session, store: &dyn RecoveryStore, cmd: &str, p: &Value) ->
|
||||
}
|
||||
let picked = |c: &str| file.is_none_or(|f| f == c) && !left.iter().any(|l| l == c);
|
||||
let areas: BTreeSet<&str> = copies.iter().filter(|(_, c)| picked(c)).map(|(a, _)| a.as_str()).collect();
|
||||
let mut out = vec![];
|
||||
let mut out: Vec<Target> = vec![];
|
||||
for area in areas {
|
||||
let mut hold = vec![];
|
||||
match (owner(s, store, area, &mut hold, true), file) {
|
||||
let held: Vec<&str> = out.iter().map(|(a, _, _)| a.as_str()).collect();
|
||||
match (owner(s, store, area, &mut hold, true, &held), file) {
|
||||
(Owner::Gone, _) => {
|
||||
let files = copies.iter().filter(|(a, c)| a == area && picked(c)).map(|(_, c)| c.clone()).collect();
|
||||
out.push((area.to_string(), files, hold));
|
||||
|
||||
@@ -297,7 +297,8 @@ fn list_installed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false}));
|
||||
};
|
||||
let folder = root.to_string_lossy();
|
||||
crate::file_access::check_read(&folder).map_err(EngineError::Other)?;
|
||||
// The Scripts folder is the app's: a refusal names it as the request may see it.
|
||||
crate::file_access::check_read(&folder).map_err(|e| super::fileio::naming_in_error(EngineError::Other(e), &folder))?;
|
||||
let listing = script::folder::list(root);
|
||||
let scripts: Vec<Value> = listing
|
||||
.scripts()
|
||||
|
||||
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy to User Defined",
|
||||
["Window", "Swatch Libraries"],
|
||||
None,
|
||||
"{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}",
|
||||
"{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path (left out when it is outside the read roots), copied: false when nothing was written}",
|
||||
has_user_folder,
|
||||
copy_to_user
|
||||
),
|
||||
@@ -368,7 +368,9 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
.ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?;
|
||||
let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) {
|
||||
Some(p) => {
|
||||
// Outside the request's read roots: the error names no path.
|
||||
// The file's bytes are read: outside the request's read roots, the error names no
|
||||
// path. A document's swatches (the arm below) are written from the loaded library,
|
||||
// and the document is not read again.
|
||||
if !readable(&p) {
|
||||
return Err(bad(cmd, "the library's file is outside the read roots"));
|
||||
}
|
||||
@@ -469,19 +471,34 @@ fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// The file name a concealed library or path is shown by: the last part of `path` as this platform
|
||||
/// splits a path, so that outside Windows a `\` stays part of the name. Another platform's absolute
|
||||
/// path ([`crate::file_access::written_elsewhere`]), and a path this platform gives no file name (one
|
||||
/// that ends in `..`), are split after the last `/` or `\` instead. `path` itself when it has no such part.
|
||||
pub fn concealed_file_name(path: &str) -> String {
|
||||
let native = (!crate::file_access::written_elsewhere(path)).then(|| std::path::Path::new(path).file_name()).flatten();
|
||||
native.map_or_else(|| path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(), |n| n.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without
|
||||
/// roots in force.
|
||||
/// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is
|
||||
/// never readable under a read root.
|
||||
fn readable(path: &str) -> bool {
|
||||
crate::file_access::check_read(path).is_ok()
|
||||
!crate::file_access::written_elsewhere(path) && crate::file_access::check_read(path).is_ok()
|
||||
}
|
||||
|
||||
/// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each
|
||||
/// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is
|
||||
/// named. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then
|
||||
/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free
|
||||
/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. The
|
||||
/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows
|
||||
/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none.
|
||||
/// named. The file name is [`concealed_file_name`], and a path saved as another platform's absolute
|
||||
/// path counts as one the request may not read. The ids are those of the
|
||||
/// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers
|
||||
/// to). When another id already is or shows that id, the next free
|
||||
/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. A library
|
||||
/// keeps the id it was first shown under for the rest of the session, so an id a client was given
|
||||
/// finds the same library after another one with the same file name is loaded; only a library that
|
||||
/// shows that id itself (one loaded later without a file under that name) moves it to the next free
|
||||
/// one. Libraries shown for the first time are numbered the session's first, in the sorted order of
|
||||
/// their full ids, then `others`, in the same order. Without roots in force, none.
|
||||
pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = String>) -> Vec<(String, String)> {
|
||||
if crate::file_access::current().is_none() {
|
||||
return vec![];
|
||||
@@ -493,7 +510,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin
|
||||
let mut taken = BTreeSet::new();
|
||||
for ids in [session, others] {
|
||||
for id in ids {
|
||||
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (file_name(path), path));
|
||||
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (concealed_file_name(path), path));
|
||||
match name {
|
||||
Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)),
|
||||
_ => {
|
||||
@@ -502,16 +519,32 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut assigned = s.shown_library_ids.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut kept = vec![None; hidden.len()];
|
||||
for (slot, (id, _)) in kept.iter_mut().zip(&hidden) {
|
||||
if let Some(shown) = assigned.get(id).filter(|shown| !taken.contains(*shown)) {
|
||||
taken.insert(shown.clone());
|
||||
*slot = Some(shown.clone());
|
||||
}
|
||||
}
|
||||
// A new id is never one given to another library earlier in the session.
|
||||
let mut given: BTreeSet<String> = assigned.values().cloned().collect();
|
||||
hidden
|
||||
.into_iter()
|
||||
.map(|(id, name)| {
|
||||
let mut shown = format!("loaded/{name}");
|
||||
let mut n = 2;
|
||||
while taken.contains(&shown) {
|
||||
shown = format!("loaded/{name} ({n})");
|
||||
n += 1;
|
||||
}
|
||||
taken.insert(shown.clone());
|
||||
.zip(kept)
|
||||
.map(|((id, name), kept)| {
|
||||
let shown = kept.unwrap_or_else(|| {
|
||||
let mut shown = format!("loaded/{name}");
|
||||
let mut n = 2;
|
||||
while taken.contains(&shown) || given.contains(&shown) {
|
||||
shown = format!("loaded/{name} ({n})");
|
||||
n += 1;
|
||||
}
|
||||
taken.insert(shown.clone());
|
||||
given.insert(shown.clone());
|
||||
assigned.insert(id.clone(), shown.clone());
|
||||
shown
|
||||
});
|
||||
(id, shown)
|
||||
})
|
||||
.collect()
|
||||
@@ -746,8 +779,12 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"user" => (info, lib, false),
|
||||
_ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))),
|
||||
};
|
||||
let path = s.swatch_libraries.shown_path(&info.id);
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied}))
|
||||
let mut out = json!({"library": info.id, "name": info.name, "count": lib.len(), "copied": copied});
|
||||
// As the saves with `user: true`: no `path` when the request may not read the file.
|
||||
if let Some(path) = s.swatch_libraries.shown_path(&info.id) {
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The library swatches `names` stand for, each once, with the colour group it goes into (`None`:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -243,6 +243,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
|
||||
("burasagari", json!(v.burasagari)),
|
||||
("kinsoku", json!(v.kinsoku)),
|
||||
("kinsoku_type", json!(v.kinsoku_type)),
|
||||
("bunri_kinshi", json!(v.bunri_kinshi)),
|
||||
("roman_hanging", json!(v.roman_hanging)),
|
||||
("kashida", json!(v.kashida)),
|
||||
("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)),
|
||||
|
||||
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
@@ -715,6 +715,20 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// The type sizes a command takes, in points.
|
||||
pub(crate) const TYPE_SIZES: (f64, f64) = (0.1, 1296.0);
|
||||
|
||||
/// A type size `size` that a command was given: an error naming the range when it is outside
|
||||
/// [`TYPE_SIZES`].
|
||||
pub(crate) fn given_type_size(size: f64, cmd: &str) -> Result<f64> {
|
||||
let (lo, hi) = TYPE_SIZES;
|
||||
if !(lo..=hi).contains(&size) {
|
||||
let key = "size";
|
||||
return Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}")));
|
||||
}
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
/// Clamp in the units shown to the user, then compensate the text's affine. Clamping
|
||||
/// the local result would make small sizes impossible after enlarging an object.
|
||||
pub(crate) fn local_type_value(t: &TextObject, value: f64, limits: (f64, f64), horizontal: bool, cmd: &str) -> Result<f64> {
|
||||
@@ -820,10 +834,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setStyle";
|
||||
let font = text_param(p, "font", C)?.map(str::to_string);
|
||||
let style = text_param(p, "style", C)?.map(str::to_string);
|
||||
let size = length_given(p, "size", C)?;
|
||||
if size.is_some_and(|v| v <= 0.0) {
|
||||
return Err(bad(C, "size must be positive"));
|
||||
}
|
||||
let size = length_given(p, "size", C)?.map(|v| given_type_size(v, C)).transpose()?;
|
||||
let leading = match p.get("leading") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None),
|
||||
|
||||
@@ -440,6 +440,59 @@ fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() {
|
||||
assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere);
|
||||
}
|
||||
|
||||
/// Paragraph panel menu › Bunri-Kinshi: off for new type and for a paragraph saved before it, and
|
||||
/// saved only when on; `text.setFormat {bunriKinshi}` sets it per paragraph in one undo step and
|
||||
/// refuses a value that is not true or false, kinsoku None keeps it, and paragraph styles carry it.
|
||||
#[test]
|
||||
fn bunri_kinshi_is_set_per_paragraph_and_saved_only_when_on() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "あ——い\nう……え"})).unwrap()["id"].as_u64().unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert!(!text(&s).para_at(0).bunri_kinshi, "new type has it off");
|
||||
assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("bunri_kinshi").is_none());
|
||||
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap();
|
||||
assert!(!old.bunri_kinshi, "saved before the option: off");
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
// Only the second paragraph.
|
||||
let second = "あ——い\n".len();
|
||||
s.execute("text.setFormat", &json!({"bunriKinshi": true, "start": second, "end": second})).unwrap();
|
||||
assert!(!text(&s).para_at(0).bunri_kinshi && text(&s).para_at(1).bunri_kinshi);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
let saved = serde_json::to_value(text(&s).para_at(1)).unwrap();
|
||||
assert_eq!(saved["bunri_kinshi"], true);
|
||||
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(saved).unwrap();
|
||||
assert!(back.bunri_kinshi);
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert!(!back.para_at(0).bunri_kinshi && back.para_at(1).bunri_kinshi, "the file keeps it per paragraph");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!text(&s).para_at(1).bunri_kinshi);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(text(&s).para_at(1).bunri_kinshi);
|
||||
for bad in [json!("yes"), json!(1)] {
|
||||
assert!(s.execute("text.setFormat", &json!({"ids": [id], "bunriKinshi": bad, "kinsoku": "none"})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": id, "bunriKinshi": false})).is_err());
|
||||
assert!(text(&s).para_at(1).bunri_kinshi && text(&s).para_at(1).kinsoku != vectorcraft_doc::Kinsoku::None, "a refused call changes nothing");
|
||||
// Kinsoku None keeps the setting, without effect, and turning kinsoku back on restores it.
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "kinsoku": "none"})).unwrap();
|
||||
assert!(text(&s).para_at(1).bunri_kinshi);
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "kinsoku": "hard"})).unwrap();
|
||||
assert!(text(&s).para_at(1).bunri_kinshi);
|
||||
// A paragraph style carries it.
|
||||
s.execute("paraStyle.new", &json!({"name": "Pairs", "attrs": {"bunri_kinshi": true}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Pairs", "id": id})).unwrap();
|
||||
assert!(text(&s).para_at(0).bunri_kinshi && text(&s).para_at(1).bunri_kinshi);
|
||||
s.execute("paraStyle.new", &json!({"name": "Split", "attrs": {"bunri_kinshi": false}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Split", "id": id})).unwrap();
|
||||
assert!(!text(&s).para_at(0).bunri_kinshi && !text(&s).para_at(1).bunri_kinshi);
|
||||
}
|
||||
|
||||
/// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one
|
||||
/// undo step, the paragraph is saved with it only when it is not None and opens with it, an
|
||||
/// unknown value changes nothing, and paragraph styles carry it.
|
||||
|
||||
@@ -27,6 +27,7 @@ mod csv;
|
||||
mod library;
|
||||
|
||||
use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import};
|
||||
pub(crate) use library::{give_svg_library, svg_library};
|
||||
use library::{load_library, save_library};
|
||||
|
||||
/// The longest variable or dataset name, in characters (names arrive from MCP and files).
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{
|
||||
BTreeMap, BTreeSet, DataSet, DataValue, EngineError, MAX_NAME, Result, Session, Value, Variable, VariableKind, absolute_path, bad, json, named,
|
||||
read_file_head, str_param, variables_unlocked, write_file,
|
||||
BTreeMap, BTreeSet, DataSet, DataValue, Document, EngineError, MAX_NAME, NodeId, NodeKind, Result, Session, Value, Variable, VariableKind,
|
||||
Variables, absolute_path, bad, json, named, read_file_head, str_param, variables_unlocked, write_file,
|
||||
};
|
||||
|
||||
/// The namespace of a variable library's elements, the default namespace of its data sets' values,
|
||||
@@ -169,12 +171,20 @@ fn without_doctype(xml: &str, cmd: &str) -> Result<String> {
|
||||
}
|
||||
|
||||
/// A variable library as Load Variable Library reads it: the names of its text variables in the
|
||||
/// order it declares them, the name and `trait` of each variable of another kind, and its data
|
||||
/// sets with their text values.
|
||||
struct Library {
|
||||
/// order it declares them, the name and `trait` of each variable of another kind, its data sets
|
||||
/// with their text values, and the element id each text variable's `docRef` names.
|
||||
pub(crate) struct Library {
|
||||
text: Vec<String>,
|
||||
other: Vec<(String, String)>,
|
||||
datasets: Vec<DataSet>,
|
||||
refs: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
/// The element ids the text variables' `docRef`s name.
|
||||
pub(crate) fn elements(&self) -> std::collections::HashSet<String> {
|
||||
self.refs.iter().map(|(_, e)| e.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a variable library: XML whose `variableSets` element holds `variableSet` elements, each
|
||||
@@ -200,7 +210,7 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> {
|
||||
return Err(bad(cmd, "the file holds no `variableSets` element"));
|
||||
};
|
||||
let sets: Vec<roxmltree::Node> = root.children().filter(|n| is(n, "variableSet")).collect();
|
||||
let (mut text, mut other, mut declared) = (Vec::new(), Vec::new(), BTreeSet::new());
|
||||
let (mut text, mut other, mut declared, mut refs) = (Vec::new(), Vec::new(), BTreeSet::new(), Vec::new());
|
||||
for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) {
|
||||
let name = named(var.attribute("varName"), "varName", cmd)?;
|
||||
if !declared.insert(name.clone()) {
|
||||
@@ -210,7 +220,14 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> {
|
||||
return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables")));
|
||||
}
|
||||
match var.attribute("trait") {
|
||||
Some("textcontent") => text.push(name),
|
||||
Some("textcontent") => {
|
||||
// `docRef="id('…')"` names the element of the variable's bound object.
|
||||
let element = var.attribute("docRef").and_then(|r| r.trim().strip_prefix("id('")?.strip_suffix("')")).filter(|e| !e.is_empty());
|
||||
if let Some(element) = element {
|
||||
refs.push((name.clone(), element.to_string()));
|
||||
}
|
||||
text.push(name);
|
||||
}
|
||||
Some(t) => other.push((name, t.chars().take(MAX_NAME).collect())),
|
||||
None => return Err(bad(cmd, format!("variable `{name}` has no `trait`"))),
|
||||
}
|
||||
@@ -253,7 +270,152 @@ fn parse_library(xml: &str, cmd: &str) -> Result<Library> {
|
||||
}
|
||||
datasets.push(DataSet { name, values });
|
||||
}
|
||||
Ok(Library { text, other, datasets })
|
||||
Ok(Library { text, other, datasets, refs })
|
||||
}
|
||||
|
||||
/// The variable library an SVG carries (SVG Options › Include Variable Data), read as
|
||||
/// [`parse_library`] reads one, or `None` when the SVG holds no `variableSets` element. The
|
||||
/// element is read on its own, from its start tag to the end tag that closes it
|
||||
/// ([`variable_sets_range`]), with each reference to an
|
||||
/// entity whose name starts with `ns_` replaced by the value the text before the element declares
|
||||
/// for it (up to [`MAX_LIBRARY_ENTITIES`] of them, plain text of at most [`MAX_NAME`] bytes), as
|
||||
/// files name the library's namespaces with such entities. The `ns_vars`, `ns_custom` and
|
||||
/// `ns_flows` entities take the library's namespaces when the file doesn't declare them. An
|
||||
/// element larger than [`MAX_LIBRARY_BYTES`], before or after the replacements, is refused.
|
||||
pub(crate) fn svg_library(svg: &str) -> Option<Result<Library>> {
|
||||
const C: &str = "document.open";
|
||||
let (start, end) = variable_sets_range(svg)?;
|
||||
let read = || {
|
||||
let end = end.ok_or_else(|| bad(C, "the variable library declares no variables"))?;
|
||||
let element = svg.get(start..end).unwrap_or_default();
|
||||
if element.len() > MAX_LIBRARY_BYTES {
|
||||
return Err(bad(C, format!("the variable library is larger than {MAX_LIBRARY_BYTES} bytes")));
|
||||
}
|
||||
let mut entities: Vec<(String, String)> = Vec::new();
|
||||
for decl in svg.get(..start).unwrap_or_default().split("<!ENTITY").skip(1).take(MAX_LIBRARY_ENTITIES) {
|
||||
let decl = decl.trim_start();
|
||||
let Some((name, rest)) = decl.split_once(|c: char| c.is_ascii_whitespace()) else { continue };
|
||||
let rest = rest.trim_start();
|
||||
let Some(quote) = rest.chars().next().filter(|q| matches!(q, '"' | '\'')) else { continue };
|
||||
let Some(value) = rest.get(1..).and_then(|v| v.split(quote).next()) else { continue };
|
||||
if name.starts_with("ns_") && name.len() <= MAX_NAME && value.len() <= MAX_NAME && !value.contains(['<', '&', '%', '"']) {
|
||||
entities.push((format!("&{name};"), value.to_string()));
|
||||
}
|
||||
}
|
||||
for (name, value) in [("&ns_vars;", NS_VARIABLES), ("&ns_custom;", NS_CUSTOM), ("&ns_flows;", NS_FLOWS)] {
|
||||
if !entities.iter().any(|(n, _)| n == name) {
|
||||
entities.push((name.to_string(), value.to_string()));
|
||||
}
|
||||
}
|
||||
// Counted before anything is replaced, so a hostile file can't make a large text first.
|
||||
let grown =
|
||||
entities.iter().map(|(name, value)| element.matches(name.as_str()).count() * value.len().saturating_sub(name.len())).sum::<usize>();
|
||||
if element.len() + grown > MAX_LIBRARY_BYTES {
|
||||
return Err(bad(C, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes")));
|
||||
}
|
||||
let mut element = element.to_string();
|
||||
for (name, value) in &entities {
|
||||
element = element.replace(name.as_str(), value);
|
||||
}
|
||||
parse_library(&format!("<svg>{element}</svg>"), C)
|
||||
};
|
||||
Some(read())
|
||||
}
|
||||
|
||||
/// Where the first `variableSets` element of `svg` that is markup starts, and where the end tag
|
||||
/// that closes it ends (`None` when no end tag does), or `None` when there is no such element.
|
||||
/// Comments, CDATA sections, processing instructions and the document type declaration are
|
||||
/// skipped, so text inside them that looks like the element is not taken for it.
|
||||
fn variable_sets_range(svg: &str) -> Option<(usize, Option<usize>)> {
|
||||
const NAME: &str = "variableSets";
|
||||
// The element's name ends at white space, `>` or `/`.
|
||||
let named = |rest: &str| rest.strip_prefix(NAME).is_some_and(|r| r.starts_with(|c: char| c.is_ascii_whitespace() || matches!(c, '>' | '/')));
|
||||
if !svg.contains("<variableSets") {
|
||||
return None;
|
||||
}
|
||||
let (mut at, mut start, mut depth) = (0, None, 0usize);
|
||||
while let Some(off) = svg.get(at..).and_then(|s| s.find('<')) {
|
||||
at += off;
|
||||
let rest = svg.get(at..).unwrap_or_default();
|
||||
// The end of a construct that holds text, or of the tag at `at`.
|
||||
let past = |close: &str| rest.find(close).map(|e| at + e + close.len());
|
||||
let next = if rest.starts_with("<!--") {
|
||||
past("-->")
|
||||
} else if rest.starts_with("<![CDATA[") {
|
||||
past("]]>")
|
||||
} else if rest.starts_with("<?") {
|
||||
past("?>")
|
||||
} else if rest.starts_with("<!DOCTYPE") {
|
||||
// An internal subset ends at `]`, then the declaration at `>`.
|
||||
match (rest.find('['), rest.find('>')) {
|
||||
(Some(open), Some(gt)) if open < gt => {
|
||||
rest.get(open..).and_then(|s| s.find(']')).and_then(|c| rest.get(open + c..)?.find('>').map(|g| at + open + c + g + 1))
|
||||
}
|
||||
(_, gt) => gt.map(|g| at + g + 1),
|
||||
}
|
||||
} else if let Some(close) = rest.strip_prefix("</") {
|
||||
let tag_end = past(">");
|
||||
if let Some(s) = start
|
||||
&& named(close)
|
||||
{
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
return Some((s, tag_end));
|
||||
}
|
||||
}
|
||||
tag_end
|
||||
} else {
|
||||
let tag_end = past(">");
|
||||
if named(rest.get(1..).unwrap_or_default()) {
|
||||
let empty = tag_end.and_then(|e| svg.get(..e)).is_some_and(|t| t.ends_with("/>"));
|
||||
if start.is_none() {
|
||||
start = Some(at);
|
||||
}
|
||||
if empty && depth == 0 {
|
||||
return Some((at, tag_end));
|
||||
}
|
||||
depth += usize::from(!empty);
|
||||
}
|
||||
tag_end
|
||||
};
|
||||
let Some(next) = next else { break };
|
||||
at = next;
|
||||
}
|
||||
start.map(|s| (s, None))
|
||||
}
|
||||
|
||||
/// Give `doc`, opened from an SVG, the variable data `lib` it carries ([`svg_library`]): the
|
||||
/// library's text variables, its data sets, no active data set, and each text variable bound to
|
||||
/// the type object made of the element its `docRef` names, or the one type object among the
|
||||
/// objects a group made of it holds (`found`: the object made of each element, by id). A `docRef`
|
||||
/// that names no type object, or one another variable is bound to already, binds nothing. The art
|
||||
/// stays as the file draws it. The warning about the variables of other kinds that were left out,
|
||||
/// if any.
|
||||
pub(crate) fn give_svg_library(doc: &mut Document, lib: Library, found: &HashMap<String, NodeId>) -> Option<String> {
|
||||
let mut vars =
|
||||
Variables { variables: lib.text.iter().map(|n| Variable { name: n.clone(), kind: VariableKind::Text }).collect(), ..Variables::default() };
|
||||
vars.datasets = lib.datasets;
|
||||
// A type object written with a paint of its own, or with fills and strokes of its appearance,
|
||||
// is a group around its text (and the appearance's paths).
|
||||
let text_of = |id: NodeId| match doc.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(_)) => Some(id),
|
||||
Some(NodeKind::Group { children, .. }) => {
|
||||
let mut texts = children.iter().filter(|c| matches!(c.kind, NodeKind::Text(_)));
|
||||
match (texts.next(), texts.next()) {
|
||||
(Some(one), None) => Some(one.id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
for (name, element) in &lib.refs {
|
||||
let Some(id) = found.get(element).and_then(|id| text_of(*id)) else { continue };
|
||||
if !vars.bindings.contains_key(&id) {
|
||||
vars.bind(id, name);
|
||||
}
|
||||
}
|
||||
doc.variables = vars.into();
|
||||
left_out_warning(lib.other.iter().map(|(_, t)| t.as_str()))
|
||||
}
|
||||
|
||||
/// Load Variable Library: replace the document's variables and data sets with a variable library's
|
||||
|
||||
@@ -84,7 +84,7 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) {
|
||||
/// Queries that apply the edit the active tool is computing before they run, as edits do
|
||||
/// ([`Session::finish_tool_edit`]): a batch runs edits, Find Objects with `select`, Select All
|
||||
/// Instances and Lock Guides change the selection, and the proof views, the preferences and added
|
||||
/// fonts give the documents new revisions (they draw again, and type lays out again).
|
||||
/// fonts can give the documents new revisions (they draw again, and type lays out again).
|
||||
const WAIT_FOR_TOOL_EDIT: &[&str] = &[
|
||||
"script.run",
|
||||
"command.batch",
|
||||
|
||||
@@ -215,6 +215,20 @@ pub fn check_read(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Read))
|
||||
}
|
||||
|
||||
/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one
|
||||
/// that starts with a drive (`C:`) or a `\`? [`check_read`] reads it as a name in the working folder.
|
||||
pub fn written_elsewhere(path: &str) -> bool {
|
||||
let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic());
|
||||
!cfg!(windows) && foreign && current().is_some()
|
||||
}
|
||||
|
||||
/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`,
|
||||
/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`,
|
||||
/// `destinationFolder`)?
|
||||
pub fn is_path_key(key: &str) -> bool {
|
||||
matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end))
|
||||
}
|
||||
|
||||
/// May `path` be written here? Always, without roots in force.
|
||||
pub fn check_write(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Write))?;
|
||||
|
||||
@@ -287,7 +287,8 @@ fn document_base(s: &Session) -> Value {
|
||||
"title": st.title(),
|
||||
// The File Info title (`file.info`): the title the file gave itself, else the document's name. `title` is the tab's name.
|
||||
"fileInfoTitle": d.info_title(),
|
||||
"path": st.path,
|
||||
// Null for a confined caller when the file is outside its read roots.
|
||||
"path": st.path.as_deref().filter(|p| crate::file_access::check_read(p).is_ok()),
|
||||
"dirty": st.is_dirty(),
|
||||
"revision": st.revision,
|
||||
"units": d.units.label(),
|
||||
|
||||
@@ -27,6 +27,9 @@ pub struct Interaction {
|
||||
/// A transform or a perspective edit with `copy` keeps the copies it made of the snapshot here
|
||||
/// ([`PreviewCopies`]).
|
||||
pub(crate) copies: Option<Arc<PreviewCopies>>,
|
||||
/// The bounding box of `selection` in `doc`, measured once for a move drag
|
||||
/// ([`crate::Session::move_drag_box`]): each step offsets it by the drag's move.
|
||||
pub(crate) move_box: std::sync::OnceLock<Option<vectorcraft_doc::OrientedBox>>,
|
||||
}
|
||||
|
||||
/// What Object → Transform → Transform Again (⌘D) repeats ([`SessionState::last_transform`]).
|
||||
|
||||
@@ -25,6 +25,8 @@ pub struct Journal {
|
||||
bytes: usize,
|
||||
/// Keep every entry from this number on ([`Journal::hold`]).
|
||||
held: Option<usize>,
|
||||
/// How many times kept entries were changed, dropped or renumbered ([`Journal::rewrites`]).
|
||||
rewrites: u64,
|
||||
}
|
||||
|
||||
/// Journals with the same entries (by number) are equal, whatever their history.
|
||||
@@ -66,14 +68,24 @@ impl Journal {
|
||||
self.entries.get(i.checked_sub(self.dropped)?)
|
||||
}
|
||||
pub fn get_mut(&mut self, i: usize) -> Option<&mut JournalEntry> {
|
||||
self.rewrites += 1;
|
||||
self.entries.get_mut(i.checked_sub(self.dropped)?)
|
||||
}
|
||||
pub fn last(&self) -> Option<&JournalEntry> {
|
||||
self.entries.last()
|
||||
}
|
||||
pub fn last_mut(&mut self) -> Option<&mut JournalEntry> {
|
||||
self.rewrites += 1;
|
||||
self.entries.last_mut()
|
||||
}
|
||||
/// A number that grows whenever a kept entry may have changed, gone or been renumbered
|
||||
/// ([`Journal::truncate`], [`Journal::remove`], [`Journal::replace`], [`Journal::get_mut`],
|
||||
/// [`Journal::last_mut`]), and that [`Journal::push`] and letting old entries go leave as it
|
||||
/// is. While it stays the same, the entries from a held number on are the ones counted before,
|
||||
/// with the new ones after them.
|
||||
pub fn rewrites(&self) -> u64 {
|
||||
self.rewrites
|
||||
}
|
||||
pub fn push(&mut self, e: JournalEntry) {
|
||||
let size = entry_bytes(&e);
|
||||
self.entries.push(e);
|
||||
@@ -83,6 +95,7 @@ impl Journal {
|
||||
}
|
||||
/// Drop the entries from number `len` on.
|
||||
pub fn truncate(&mut self, len: usize) {
|
||||
self.rewrites += 1;
|
||||
let keep = len.saturating_sub(self.dropped);
|
||||
self.entries.truncate(keep);
|
||||
self.sizes.truncate(keep);
|
||||
@@ -92,6 +105,7 @@ impl Journal {
|
||||
/// Drop the entries numbered `numbers` (sorted, a number may repeat; the entries of canceled undo
|
||||
/// groups). The entries after them are numbered down to close the gaps.
|
||||
pub fn remove(&mut self, numbers: &[usize]) {
|
||||
self.rewrites += 1;
|
||||
let dropped = self.dropped;
|
||||
let gone = |i: usize| numbers.binary_search(&(dropped + i)).is_ok();
|
||||
let mut i = 0;
|
||||
@@ -115,6 +129,7 @@ impl Journal {
|
||||
}
|
||||
/// Put `e` in place of entry number `i`, if kept.
|
||||
pub fn replace(&mut self, i: usize, e: JournalEntry) {
|
||||
self.rewrites += 1;
|
||||
let Some(k) = i.checked_sub(self.dropped) else { return };
|
||||
if let (Some(old), Some(size)) = (self.entries.get_mut(k), self.sizes.get_mut(k)) {
|
||||
*size = entry_bytes(&e);
|
||||
@@ -253,4 +268,29 @@ mod tests {
|
||||
j.replace(29_000, big(0));
|
||||
assert_eq!(j.bytes, j.iter().map(entry_bytes).sum::<usize>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_count_changes_to_kept_entries_but_not_new_ones() {
|
||||
let mut j = Journal::default();
|
||||
j.hold(Some(0));
|
||||
for i in 0..100 {
|
||||
j.push(("object.move".into(), json!({"dx": i})));
|
||||
}
|
||||
assert_eq!(j.rewrites(), 0, "pushes add entries after the counted ones");
|
||||
let changes: [fn(&mut Journal); 5] = [
|
||||
|j| j.truncate(90),
|
||||
|j| j.remove(&[3]),
|
||||
|j| j.replace(4, ("object.group".into(), json!({}))),
|
||||
|j| {
|
||||
let _ = j.get_mut(5);
|
||||
},
|
||||
|j| {
|
||||
let _ = j.last_mut();
|
||||
},
|
||||
];
|
||||
for (i, change) in changes.into_iter().enumerate() {
|
||||
change(&mut j);
|
||||
assert_eq!(j.rewrites(), i as u64 + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,9 @@ pub struct Session {
|
||||
pub symbol_libraries: cmd::symbollib::Libraries,
|
||||
/// User Defined and loaded brush libraries (Window → Brush Libraries); not saved.
|
||||
pub brush_libraries: cmd::brushlib::Libraries,
|
||||
/// The id each loaded library outside a request's read roots has been shown under, by its own id
|
||||
/// ([`cmd::swatchlib::concealed_library_ids`]), kept for the session.
|
||||
shown_library_ids: std::sync::Mutex<std::collections::BTreeMap<String, String>>,
|
||||
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
|
||||
pub libraries: cmd::library::Libraries,
|
||||
/// Where the saved PDF presets are kept, one file each; `None`: only for the session
|
||||
|
||||
@@ -71,6 +71,11 @@ pub struct Prefs {
|
||||
/// A single-key tool shortcut held down chooses its tool until the key is let go, and then the
|
||||
/// tool active before comes back; a quick press chooses the tool for good.
|
||||
pub hold_tool_keys: bool,
|
||||
/// With View › Show Bounding Box on, the bounding box stays visible while a drag moves the
|
||||
/// selection, and the shape tools that show it draw it around the shape being drawn. Off (the
|
||||
/// default), it hides during a move and while a shape is drawn, except that a Selection tool
|
||||
/// move with Real-time Drawing off still moves the box.
|
||||
pub bounding_box_while_moving_and_drawing: bool,
|
||||
/// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this).
|
||||
pub paste_offset: f64,
|
||||
// Selection & Anchor Display
|
||||
@@ -397,6 +402,7 @@ impl Default for Prefs {
|
||||
scrub_numeric_fields: true,
|
||||
trackpad_rotate_view: true,
|
||||
hold_tool_keys: true,
|
||||
bounding_box_while_moving_and_drawing: false,
|
||||
paste_offset: 10.0,
|
||||
selection_tolerance: 3.0,
|
||||
marquee_selection: "touching".into(),
|
||||
|
||||
@@ -723,7 +723,7 @@ struct Host {
|
||||
/// order: the error's mark ([`HostError::Marked`]) is its index.
|
||||
eval_failures: RefCell<Vec<(Arc<str>, u32, u32)>>,
|
||||
/// The realm's tag for the script ([`Realm::script_tag`]) once the run has started it, and
|
||||
/// [`vectorcraft_js::UNKNOWN_SOURCE`] before.
|
||||
/// [`vectorcraft_js::UNKNOWN_SOURCE`] before or when the script did not compile.
|
||||
script_tag: Cell<u32>,
|
||||
/// The run's name as its report gives it, which [`Host::name`] shortens for console lines.
|
||||
report_name: Arc<str>,
|
||||
@@ -1865,7 +1865,10 @@ impl Session {
|
||||
.and_then(|()| if fresh { files::install(&mut realm, &link) } else { realm.reset_folder_current() })
|
||||
.and_then(|()| {
|
||||
let r = realm.start(&expanded.source);
|
||||
host.script_tag.set(realm.script_tag());
|
||||
// Source that does not compile defines no code, so its map is not kept.
|
||||
if !matches!(&r, Err(e) if e.kind == RunErrorKind::Syntax) {
|
||||
host.script_tag.set(realm.script_tag());
|
||||
}
|
||||
r
|
||||
})
|
||||
.map_err(|e| located(&host, &realm, &e))
|
||||
|
||||
@@ -80,6 +80,7 @@ impl Session {
|
||||
style_libraries: Default::default(),
|
||||
symbol_libraries: Default::default(),
|
||||
brush_libraries: Default::default(),
|
||||
shown_library_ids: Default::default(),
|
||||
libraries: Default::default(),
|
||||
pdf_preset_dir: None,
|
||||
dictionary_file: None,
|
||||
|
||||
@@ -269,6 +269,7 @@ impl Session {
|
||||
isolation: st.isolation,
|
||||
perspective_again: None,
|
||||
copies: None,
|
||||
move_box: std::sync::OnceLock::new(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -375,3 +375,72 @@ fn the_selection_tool_reports_a_transforming_drag() {
|
||||
assert!(s.tool_busy() && !s.tool_transforming());
|
||||
s.pointer(&ev(PointerKind::Up, 550.0, 450.0), v).unwrap();
|
||||
}
|
||||
|
||||
/// With General › Show Bounding Box While Moving and Drawing on, a move drag's bounding box is the
|
||||
/// box measured once, at the drag's start, offset by each step's move: with Real-time Drawing on
|
||||
/// and off, the later steps do not measure the selection again. Off, only the Selection tool with
|
||||
/// Real-time Drawing off gives a box (the art waits for the release).
|
||||
#[test]
|
||||
fn a_move_drag_measures_the_bounding_box_once() {
|
||||
for (real_time, while_moving) in [(true, true), (false, true), (true, false), (false, false)] {
|
||||
let what = format!("real time {real_time}, box while moving {while_moving}");
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
|
||||
sel(&mut s, &[id]);
|
||||
s.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap();
|
||||
s.execute("prefs.set", &json!({"key": "boundingBoxWhileMovingAndDrawing", "value": while_moving})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y).with_mods(Mods::default());
|
||||
s.pointer(&ev(PointerKind::Down, 150.0, 125.0), v).unwrap();
|
||||
assert_eq!(s.move_drag_box(), None, "{what}: not before the drag begins");
|
||||
s.pointer(&ev(PointerKind::Drag, 170.0, 135.0), v).unwrap();
|
||||
if real_time && !while_moving {
|
||||
assert_eq!(s.move_drag_box(), None, "{what}: no box during the move");
|
||||
s.pointer(&ev(PointerKind::Up, 170.0, 135.0), v).unwrap();
|
||||
continue;
|
||||
}
|
||||
assert_eq!(s.move_drag_box(), Some(OrientedBox::aligned(Rect::new(120.0, 110.0, 220.0, 160.0))), "{what}");
|
||||
// The measurement made at the drag's start is the one each step offsets: replaced by a
|
||||
// stand-in, the next step's box is the stand-in moved.
|
||||
let it = s.doc_mut().unwrap().interaction.as_mut().unwrap();
|
||||
assert_eq!(it.move_box.get(), Some(&Some(OrientedBox::aligned(Rect::new(100.0, 100.0, 200.0, 150.0)))), "{what}: measured at the start");
|
||||
it.move_box = std::sync::OnceLock::from(Some(OrientedBox::aligned(Rect::new(0.0, 0.0, 10.0, 10.0))));
|
||||
s.pointer(&ev(PointerKind::Drag, 200.0, 165.0), v).unwrap();
|
||||
assert_eq!(s.move_drag_box(), Some(OrientedBox::aligned(Rect::new(50.0, 40.0, 60.0, 50.0))), "{what}: not measured again");
|
||||
s.pointer(&ev(PointerKind::Up, 200.0, 165.0), v).unwrap();
|
||||
assert_eq!(s.move_drag_box(), None, "{what}: none after the release");
|
||||
assert_eq!(selection_box(&s), OrientedBox::aligned(Rect::new(150.0, 140.0, 250.0, 190.0)), "{what}: moved");
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape being drawn has no preview applied after a step that failed (with Real-time Drawing on,
|
||||
/// also when the failed command left a document other than the interaction's snapshot), and with
|
||||
/// Real-time Drawing off, where the document stays at the snapshot. A successful step with Real-time
|
||||
/// Drawing on applies it.
|
||||
#[test]
|
||||
fn a_shape_being_drawn_reports_whether_a_preview_is_applied() {
|
||||
for real_time in [true, false] {
|
||||
let what = format!("real time {real_time}");
|
||||
let mut s = session();
|
||||
s.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("rectangle", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 160.0, 140.0), v).unwrap();
|
||||
assert_eq!(s.interaction_unapplied(), !real_time, "{what}: after a step that succeeded");
|
||||
assert!(s.preview("shape.rectangle", &json!({"x": "left"})).is_err(), "{what}: the preview fails");
|
||||
assert!(s.interaction_unapplied(), "{what}: after a step that failed");
|
||||
if real_time {
|
||||
// A failed command that copied the document on write leaves another document than the
|
||||
// snapshot; no preview is applied all the same.
|
||||
let st = s.doc_mut().unwrap();
|
||||
st.doc = std::sync::Arc::new((*st.doc).clone());
|
||||
assert!(s.interaction_unapplied(), "{what}: a failed step with a copied document");
|
||||
}
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 150.0), v).unwrap();
|
||||
assert_eq!(s.interaction_unapplied(), !real_time, "{what}: the next step succeeded");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 150.0), v).unwrap();
|
||||
assert!(!s.interaction_unapplied(), "{what}: no interaction after the release");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +405,143 @@ fn a_document_with_bad_blend_easing_numbers_opens_clamped_while_commands_refuse(
|
||||
}
|
||||
}
|
||||
|
||||
/// `object.blend.setEasing` sets one blend's easing and color shift, records no undo step when
|
||||
/// nothing changes, and refuses bad values, a blend whose steps follow no spine (easing only), a
|
||||
/// hidden or locked blend and a target that is not a blend, leaving the blend and the history alone.
|
||||
#[test]
|
||||
fn set_easing_edits_one_blend_and_refuses_what_it_cannot_set() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0));
|
||||
sel(&mut s, &[a, b]);
|
||||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap());
|
||||
let depth = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let d0 = depth(&s);
|
||||
// Align to Page along the straight lines between the keys: the steps follow no spine.
|
||||
for bad in [json!({"ramp": 30}), json!({"easingType": "easeOut"})] {
|
||||
let e = s.execute("object.blend.setEasing", &bad).unwrap_err().to_string();
|
||||
assert!(e.contains("the blend has no spine"), "{e}");
|
||||
}
|
||||
assert_eq!(depth(&s), d0);
|
||||
let r = s.execute("object.blend.setEasing", &json!({"colorShift": 25})).unwrap();
|
||||
assert_eq!(r, json!({"id": g.0, "easingType": "easeIn", "ramp": 0.0, "colorShift": 25.0, "changed": true}));
|
||||
assert_eq!((spec(&s, g).1.color_shift, depth(&s)), (25.0, d0 + 1));
|
||||
let r = s.execute("object.blend.setEasing", &json!({"colorShift": 25})).unwrap();
|
||||
assert_eq!((&r["changed"], depth(&s)), (&json!(false), d0 + 1), "an unchanged value records no undo step");
|
||||
// Along the spine each of the four types keeps its kind through a ramp change and its undo.
|
||||
s.execute("object.blend.options", &json!({"orientation": "path"})).unwrap();
|
||||
for kind in ["easeIn", "easeOut", "easeInOut", "easeOutIn"] {
|
||||
s.execute("object.blend.setEasing", &json!({"easingType": kind, "ramp": 60})).unwrap();
|
||||
let r = s.execute("object.blend.setEasing", &json!({"ramp": 20})).unwrap();
|
||||
assert_eq!((&r["easingType"], &r["ramp"], &r["colorShift"]), (&json!(kind), &json!(20.0), &json!(25.0)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let sp = spec(&s, g).1;
|
||||
assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift), (kind, 0.6, 25.0));
|
||||
}
|
||||
let before = spec(&s, g).1;
|
||||
let d1 = depth(&s);
|
||||
for bad in [
|
||||
json!({"ramp": 101}),
|
||||
json!({"ramp": -1}),
|
||||
json!({"ramp": "x"}),
|
||||
json!({"colorShift": 1e308}),
|
||||
json!({"colorShift": -101}),
|
||||
json!({"easingType": "sideways"}),
|
||||
json!({"id": "abc", "ramp": 10}),
|
||||
json!({"id": a.0, "ramp": 10}),
|
||||
] {
|
||||
assert!(s.execute("object.blend.setEasing", &bad).is_err(), "{bad}");
|
||||
assert_eq!((spec(&s, g).1, depth(&s)), (before.clone(), d1), "{bad}");
|
||||
}
|
||||
// An explicit id works with nothing selected; nothing selected and no id is an error.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("object.blend.setEasing", &json!({"ramp": 10})).is_err());
|
||||
s.execute("object.blend.setEasing", &json!({"id": g.0, "ramp": 10})).unwrap();
|
||||
assert_eq!(spec(&s, g).1.ease.ramp, 0.1);
|
||||
// A hidden or locked blend is refused.
|
||||
for hide in [true, false] {
|
||||
s.edit("state", |d, _| {
|
||||
let n = d.node_mut(g).unwrap();
|
||||
(n.visible, n.locked) = (!hide, !hide);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (before, d2) = (spec(&s, g).1, depth(&s));
|
||||
let e = s.execute("object.blend.setEasing", &json!({"id": g.0, "colorShift": 5})).unwrap_err().to_string();
|
||||
assert!(e.contains("the object is hidden or locked"), "{e}");
|
||||
assert_eq!((spec(&s, g).1, depth(&s)), (before, d2));
|
||||
}
|
||||
// A blend made without a spine takes no easing, Align to Path included; its color shift still
|
||||
// changes.
|
||||
let (c, d) = (rect(&mut s, 0.0, 300.0, 40.0), rect(&mut s, 200.0, 300.0, 40.0));
|
||||
sel(&mut s, &[c, d]);
|
||||
let n = id_of(&s.execute("object.blend.make", &json!({"createSpine": "never", "orientation": "path"})).unwrap());
|
||||
let d3 = depth(&s);
|
||||
let e = s.execute("object.blend.setEasing", &json!({"id": n.0, "ramp": 30})).unwrap_err().to_string();
|
||||
assert!(e.contains("the blend has no spine"), "{e}");
|
||||
assert_eq!(depth(&s), d3);
|
||||
s.execute("object.blend.setEasing", &json!({"id": n.0, "colorShift": 10})).unwrap();
|
||||
assert_eq!(spec(&s, n).1.color_shift, 10.0);
|
||||
// Replace Spine gives it a spine, and it takes easing from then on.
|
||||
let path = id_of(&s.execute("path.create", &json!({"d": "M0 320 L200 320"})).unwrap());
|
||||
sel(&mut s, &[n, path]);
|
||||
s.execute("object.blend.replaceSpine", &json!({})).unwrap();
|
||||
let r = s.execute("object.blend.setEasing", &json!({"id": n.0, "easingType": "easeOut", "ramp": 30})).unwrap();
|
||||
assert_eq!((&r["easingType"], &r["ramp"], &r["changed"]), (&json!("easeOut"), &json!(30.0), &json!(true)));
|
||||
}
|
||||
|
||||
/// Dragging a blend widget with the Selection tool or the Blend tool is one undo step; a click, a
|
||||
/// drag that ends at its starting value and a drag canceled with Escape record none, and Escape
|
||||
/// puts the value back.
|
||||
#[test]
|
||||
fn dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0));
|
||||
sel(&mut s, &[a, b]);
|
||||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "orientation": "path", "easingType": "easeOut"})).unwrap());
|
||||
let v = ViewInfo::default();
|
||||
let depth = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let ptr = |s: &mut Session, kind: PointerKind, x: f64, y: f64| {
|
||||
s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap();
|
||||
};
|
||||
s.select_tool("selection", v).unwrap();
|
||||
sel(&mut s, &[g]);
|
||||
let d0 = depth(&s);
|
||||
// The Easing widget sits 16 px below the spine's halfway point (120, 20).
|
||||
ptr(&mut s, PointerKind::Down, 120.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Drag, 135.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Drag, 150.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Up, 150.0, 36.0);
|
||||
let sp = spec(&s, g).1;
|
||||
assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift, depth(&s)), ("easeOut", 0.3, 0.0, d0 + 1));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g], "the blend stays selected");
|
||||
// A click, and a drag back to where it started.
|
||||
ptr(&mut s, PointerKind::Down, 120.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Up, 120.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Down, 120.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Drag, 160.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Drag, 120.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Up, 120.0, 36.0);
|
||||
assert_eq!((spec(&s, g).1.ease.ramp, depth(&s)), (0.3, d0 + 1));
|
||||
// Escape during a drag puts the value back.
|
||||
ptr(&mut s, PointerKind::Down, 120.0, 36.0);
|
||||
ptr(&mut s, PointerKind::Drag, 170.0, 36.0);
|
||||
assert_eq!(spec(&s, g).1.ease.ramp, 0.8, "the drag previews");
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
ptr(&mut s, PointerKind::Up, 170.0, 36.0);
|
||||
assert_eq!((spec(&s, g).1.ease.ramp, depth(&s)), (0.3, d0 + 1));
|
||||
// The Blend tool drags the Appearance widget, 16 px above: the color shift alone.
|
||||
s.select_tool("blend", v).unwrap();
|
||||
sel(&mut s, &[g]);
|
||||
ptr(&mut s, PointerKind::Down, 120.0, 4.0);
|
||||
ptr(&mut s, PointerKind::Drag, 100.0, 4.0);
|
||||
ptr(&mut s, PointerKind::Up, 100.0, 4.0);
|
||||
let sp = spec(&s, g).1;
|
||||
assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift, depth(&s)), ("easeOut", 0.3, -20.0, d0 + 2));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, g).1.color_shift, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_blend_easing_values_clamp_and_non_finite_become_default() {
|
||||
for (bad, ramp, shift) in [
|
||||
|
||||
@@ -106,7 +106,17 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams", "dry-media"]
|
||||
[
|
||||
"art-ribbons",
|
||||
"ink-and-wash",
|
||||
"calligraphic-nibs",
|
||||
"bristle-brushes",
|
||||
"pattern-borders",
|
||||
"scatter-shapes",
|
||||
"game-trails",
|
||||
"energy-beams",
|
||||
"dry-media"
|
||||
]
|
||||
);
|
||||
assert!(libs.iter().all(|l| l["category"] == "builtIn"));
|
||||
let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"}));
|
||||
@@ -120,26 +130,28 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(),
|
||||
["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"]
|
||||
);
|
||||
assert_eq!(libs[5]["count"].as_u64(), Some(4));
|
||||
assert_eq!(libs[6]["count"].as_u64(), Some(4));
|
||||
let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"}));
|
||||
assert_eq!(
|
||||
beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(),
|
||||
["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"]
|
||||
);
|
||||
assert_eq!(libs[6]["count"].as_u64(), Some(4));
|
||||
assert_eq!(libs[7]["count"].as_u64(), Some(4));
|
||||
let dry = run(&mut s, "brush.library", json!({"library": "Dry Media"}));
|
||||
assert_eq!(
|
||||
dry["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect::<Vec<_>>(),
|
||||
[("Chalk Grain", "art"), ("Graphite Pencil", "art"), ("Wax Crayon", "art"), ("Charcoal Dust", "art")]
|
||||
);
|
||||
assert_eq!((libs[7]["name"].as_str(), libs[7]["count"].as_u64()), (Some("Dry Media"), Some(4)));
|
||||
assert_eq!((libs[8]["name"].as_str(), libs[8]["count"].as_u64()), (Some("Dry Media"), Some(4)));
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap();
|
||||
let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]}));
|
||||
assert_eq!(strs(&added["added"]), ["Laser Bolt"]);
|
||||
// One kind of brush per library, every brush drawing something.
|
||||
// One kind of brush per library (Ink and Wash: art brushes and a calligraphic one), every
|
||||
// brush drawing something.
|
||||
for (id, kind) in [
|
||||
("art-ribbons", "art"),
|
||||
("ink-and-wash", "art"),
|
||||
("calligraphic-nibs", "calligraphic"),
|
||||
("bristle-brushes", "bristle"),
|
||||
("pattern-borders", "pattern"),
|
||||
@@ -151,6 +163,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
let (_, lib) = cmd::brushlib::library(&s, id).unwrap();
|
||||
assert!(lib.len() >= 4, "{id}");
|
||||
for b in &lib.brushes {
|
||||
let kind = if b.name == "Brush Pen" { "calligraphic" } else { kind };
|
||||
assert_eq!(b.kind.type_id(), kind, "{}", b.name);
|
||||
let mut line = vectorcraft_geom::BezPath::new();
|
||||
line.move_to((0.0, 0.0));
|
||||
@@ -176,6 +189,153 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err());
|
||||
}
|
||||
|
||||
/// Every path of brush art `n`: (anchors, opacity).
|
||||
fn art_paths(n: &vectorcraft_doc::Node) -> Vec<(usize, f32)> {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| {
|
||||
if let Some(p) = c.path_data() {
|
||||
out.push((p.anchor_count(), c.opacity));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The Ink and Wash library, made once.
|
||||
fn inks(s: &Session) -> std::sync::Arc<cmd::brushlib::BrushLibrary> {
|
||||
cmd::brushlib::library(s, "ink-and-wash").unwrap().1
|
||||
}
|
||||
|
||||
/// A straight line from the origin to (`x`, 0).
|
||||
fn line_to(x: f64) -> vectorcraft_geom::BezPath {
|
||||
let mut line = vectorcraft_geom::BezPath::new();
|
||||
line.move_to((0.0, 0.0));
|
||||
line.line_to((x, 0.0));
|
||||
line
|
||||
}
|
||||
|
||||
fn black_stroke() -> vectorcraft_doc::StrokeLayer {
|
||||
vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0)
|
||||
}
|
||||
|
||||
/// Ink and Wash: a tapered ink art brush, a brush pen whose size follows the pen pressure and two
|
||||
/// translucent washes, with names of their own and a few paths of art each.
|
||||
#[test]
|
||||
fn the_ink_and_wash_library_has_tapered_ink_a_brush_pen_and_two_washes() {
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "brush.library", json!({"library": "ink-and-wash"}));
|
||||
assert_eq!((lib["name"].as_str(), lib["category"].as_str()), (Some("Ink and Wash"), Some("builtIn")));
|
||||
let listed: Vec<(&str, &str)> =
|
||||
lib["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect();
|
||||
assert_eq!(listed, [("Tapered Ink", "art"), ("Brush Pen", "calligraphic"), ("Soft Wash", "art"), ("Pooling Wash", "art")]);
|
||||
assert_eq!(run(&mut s, "brush.library", json!({"library": "Ink and Wash"}))["id"], "ink-and-wash");
|
||||
let inks = inks(&s);
|
||||
// Names no other built-in library uses.
|
||||
for b in cmd::brushlib::BUILTIN.iter().filter(|b| b.id != "ink-and-wash") {
|
||||
let (_, other) = cmd::brushlib::library(&s, b.id).unwrap();
|
||||
assert!(inks.brushes.iter().all(|x| other.brush(&x.name).is_none()), "{}", b.id);
|
||||
}
|
||||
// The art: within its 100 pt length, a few paths each, opaque ink and translucent washes.
|
||||
let mut anchors = 0;
|
||||
for name in ["Tapered Ink", "Soft Wash", "Pooling Wash"] {
|
||||
let BrushKind::Art(a) = &inks.brush(name).unwrap().kind else { panic!("{name} is not an art brush") };
|
||||
assert_eq!((a.scale, a.colorization), (vectorcraft_brush::ArtScale::Stretch, vectorcraft_brush::Colorization::Tints), "{name}");
|
||||
let b = a.art.geometric_bounds().unwrap();
|
||||
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()), "{name}");
|
||||
assert!(b.x0 >= 0.0 && b.x1 <= 100.0 && b.x1 - b.x0 > 99.0, "{name}: {b:?}");
|
||||
assert!(b.y0 >= -8.0 && b.y1 <= 8.0 && b.y1 - b.y0 > 5.0, "{name}: {b:?}");
|
||||
let paths = art_paths(&a.art);
|
||||
let count: usize = paths.iter().map(|p| p.0).sum();
|
||||
assert!(paths.len() <= 80 && count <= 500, "{name}: {} paths, {count} anchors", paths.len());
|
||||
anchors += count;
|
||||
let opacities: Vec<f32> = paths.iter().map(|p| p.1).collect();
|
||||
if name == "Tapered Ink" {
|
||||
assert_eq!(opacities, [1.0], "opaque ink");
|
||||
} else {
|
||||
assert!(paths.len() >= 3, "{name}: a wash of several layers");
|
||||
assert!(opacities.iter().all(|o| (0.15..=0.55).contains(o)), "{name}: {opacities:?}");
|
||||
}
|
||||
}
|
||||
assert!(anchors <= 1200, "{anchors} anchors");
|
||||
let BrushKind::Calligraphic(pen) = &inks.brush("Brush Pen").unwrap().kind else { panic!("Brush Pen is not calligraphic") };
|
||||
use vectorcraft_brush::Variation::{Fixed, Pressure};
|
||||
assert_eq!((pen.size, pen.modes(), pen.variation), (5.0, [Fixed, Fixed, Pressure], [0.0, 0.0, 2.5]), "no random variation");
|
||||
}
|
||||
|
||||
/// Every Ink and Wash brush draws along straight, curved, short and tight paths within finite
|
||||
/// bounds, and Tapered Ink is broad near its start and fine at its end.
|
||||
#[test]
|
||||
fn ink_and_wash_brushes_draw_along_straight_curved_short_and_tight_paths() {
|
||||
let inks = inks(&Session::new());
|
||||
let mut curve = vectorcraft_geom::BezPath::new();
|
||||
curve.move_to((0.0, 0.0));
|
||||
curve.curve_to((60.0, -40.0), (140.0, 40.0), (200.0, 0.0));
|
||||
let tight = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(0.0, 0.0, 6.0, 6.0)).to_bezpath();
|
||||
let paths = [line_to(200.0), line_to(3.0), curve, tight];
|
||||
for b in &inks.brushes {
|
||||
for line in &paths {
|
||||
let pieces = vectorcraft_brush::stroke_pieces(&inks.doc, b, line, &black_stroke());
|
||||
assert!(!pieces.is_empty(), "{} draws nothing", b.name);
|
||||
let bounds: Vec<_> = pieces.iter().map(|p| p.geometric_bounds()).collect();
|
||||
assert!(bounds.iter().all(|r| r.is_some_and(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()))), "{}", b.name);
|
||||
}
|
||||
}
|
||||
let pieces = vectorcraft_brush::stroke_pieces(&inks.doc, inks.brush("Tapered Ink").unwrap(), &line_to(100.0), &black_stroke());
|
||||
let widest = |from: f64, to: f64| {
|
||||
pieces
|
||||
.iter()
|
||||
.filter_map(|n| n.path_data())
|
||||
.flat_map(|p| p.anchors().map(|(_, _, a)| a.p).collect::<Vec<_>>())
|
||||
.filter(|p| p.x >= from && p.x <= to)
|
||||
.map(|p| p.y.abs())
|
||||
.fold(0.0, f64::max)
|
||||
};
|
||||
assert!(widest(15.0, 30.0) > 4.0 * widest(95.0, 100.0), "broad start, fine tip");
|
||||
}
|
||||
|
||||
/// Ink and Wash brushes are added and applied in one step, undone and redone, not added twice,
|
||||
/// and kept with the brushed stroke by a saved and reopened document.
|
||||
#[test]
|
||||
fn ink_and_wash_brushes_add_apply_undo_and_survive_a_saved_document() {
|
||||
let mut s = session();
|
||||
let inks = inks(&s);
|
||||
let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60}))["id"].as_u64().unwrap();
|
||||
let (defaults, before) = (brushes(&s), undo_len(&s));
|
||||
let names = ["Tapered Ink", "Brush Pen", "Soft Wash", "Pooling Wash"];
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash", "name": "Soft Wash", "apply": true, "ids": [path]}));
|
||||
assert_eq!((strs(&r["added"]), r["applied"].clone(), undo_len(&s)), (vec!["Soft Wash"], json!([path]), before + 1));
|
||||
let stroke_brush = |s: &Session| doc(s).node(NodeId(path)).unwrap().appearance.stroke().and_then(|st| st.brush.clone());
|
||||
assert_eq!(stroke_brush(&s).as_deref(), Some("Soft Wash"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((brushes(&s), stroke_brush(&s)), (defaults.clone(), None));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(stroke_brush(&s).as_deref(), Some("Soft Wash"));
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash"}));
|
||||
assert_eq!(
|
||||
(strs(&r["names"]), strs(&r["existing"]), strs(&r["added"])),
|
||||
(names.to_vec(), vec!["Soft Wash"], vec!["Tapered Ink", "Brush Pen", "Pooling Wash"])
|
||||
);
|
||||
let before = undo_len(&s);
|
||||
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash"}));
|
||||
assert_eq!((strs(&r["existing"]), undo_len(&s)), (names.to_vec(), before));
|
||||
assert_eq!(brushes(&s).len(), defaults.len() + 4);
|
||||
for n in names {
|
||||
assert_eq!(&brush(&s, n), inks.brush(n).unwrap(), "{n}");
|
||||
}
|
||||
|
||||
// Saved and opened again, the document keeps the definitions and the brushed stroke.
|
||||
let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string();
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::base64_decode(&b64).unwrap()).unwrap();
|
||||
for n in names {
|
||||
assert_eq!(vectorcraft_brush::find(&reopened, n).as_ref(), inks.brush(n), "{n}");
|
||||
}
|
||||
let n = reopened.node(NodeId(path)).unwrap();
|
||||
assert_eq!(n.appearance.stroke().and_then(|st| st.brush.clone()).as_deref(), Some("Soft Wash"));
|
||||
assert_eq!(
|
||||
vectorcraft_brush::node_pieces(&reopened, n).map(|p| p.len()),
|
||||
vectorcraft_brush::node_pieces(doc(&s), doc(&s).node(NodeId(path)).unwrap()).map(|p| p.len())
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether `p` lies inside the polygon through `pts` (even-odd ray cast).
|
||||
fn inside(p: vectorcraft_geom::Point, pts: &[vectorcraft_geom::Point]) -> bool {
|
||||
let mut odd = false;
|
||||
|
||||
@@ -793,8 +793,9 @@ fn shape_builder_applies_a_merge_before_what_comes_next() {
|
||||
let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
|
||||
assert_eq!(labels, ["Move", "Shape Builder"]);
|
||||
}),
|
||||
// The proof views redraw every document (a new revision); they are left off, as the
|
||||
// other tests of the process-wide proof view expect.
|
||||
// The proof views apply the merge first by command id (`WAIT_FOR_TOOL_EDIT`), also when
|
||||
// the view does not change and no document gets a new revision. They are left off, as
|
||||
// the other tests of the process-wide proof view expect.
|
||||
("Separations Preview", |s, _, _| {
|
||||
s.execute("view.separationsPreview", &json!({"on": false})).unwrap();
|
||||
}),
|
||||
@@ -807,6 +808,13 @@ fn shape_builder_applies_a_merge_before_what_comes_next() {
|
||||
("Proof Setup", |s, _, _| {
|
||||
s.execute("view.proofSetup", &json!({})).unwrap();
|
||||
}),
|
||||
("a proof view that changes", |s, _, _| {
|
||||
let _v = crate::tests_colormgmt::DefaultView::new();
|
||||
s.execute("view.proofColors", &json!({"on": true})).unwrap();
|
||||
let on = s.doc().unwrap().revision;
|
||||
s.execute("view.proofColors", &json!({"on": false})).unwrap();
|
||||
assert!(s.doc().unwrap().revision > on, "the view changes and the documents get new revisions");
|
||||
}),
|
||||
("a preference", |s, _, _| {
|
||||
let every = s.prefs.gridline_every;
|
||||
s.execute("prefs.set", &json!({"key": "gridlineEvery", "value": every + 10.0})).unwrap();
|
||||
|
||||
@@ -308,6 +308,23 @@ fn assign_profile_is_stored_and_undoable() {
|
||||
assert_eq!(cmd::colormgmt::doc_profiles(&s.doc().unwrap().doc), (None, None));
|
||||
}
|
||||
|
||||
/// Assigning the working profiles leaves the active color settings and the other documents'
|
||||
/// revisions unchanged.
|
||||
#[test]
|
||||
fn assigning_the_working_profiles_keeps_the_active_color_settings() {
|
||||
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = session();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let working = cms::active_settings();
|
||||
let active = cms::active();
|
||||
let other = s.documents()[0].revision;
|
||||
let r = s.execute("edit.assignProfile", &json!({"rgb": working.rgb, "cmyk": working.cmyk})).unwrap();
|
||||
assert_eq!((r["rgb"].as_str(), r["cmyk"].as_str()), (Some(working.rgb.as_str()), Some(working.cmyk.as_str())));
|
||||
assert_eq!(cmd::colormgmt::doc_profiles(&s.doc().unwrap().doc), (Some(working.rgb.clone()), Some(working.cmyk.clone())));
|
||||
assert!(std::sync::Arc::ptr_eq(&active, &cms::active()), "the active color settings were replaced");
|
||||
assert_eq!(s.documents()[0].revision, other, "the other document's revision changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_colors_simulate_press_on_screen() {
|
||||
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -438,6 +455,7 @@ fn overprint_black_multiplies_in_preview() {
|
||||
#[test]
|
||||
fn view_toggles_redraw_without_dirtying() {
|
||||
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _v = DefaultView::new();
|
||||
let mut s = session();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
let r = s.execute("view.proofColors", &json!({"on": true})).unwrap();
|
||||
@@ -458,6 +476,101 @@ fn view_toggles_redraw_without_dirtying() {
|
||||
s.execute("view.overprintPreview", &json!({"on": false})).unwrap();
|
||||
}
|
||||
|
||||
/// Sets the default proof view when made and again when dropped, also after a failed assertion,
|
||||
/// so the next test starts from the default view.
|
||||
pub(crate) struct DefaultView;
|
||||
|
||||
impl DefaultView {
|
||||
pub(crate) fn new() -> Self {
|
||||
vectorcraft_render::proof::set_view(Default::default());
|
||||
DefaultView
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DefaultView {
|
||||
fn drop(&mut self) {
|
||||
vectorcraft_render::proof::set_view(Default::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `cmd` with `p` twice from the default proof view: the first run changes the view and
|
||||
/// redraws, and the second asks for the view that is already showing and leaves the revision alone.
|
||||
fn repeat_keeps_revision(cmd: &str, p: Value) {
|
||||
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _v = DefaultView::new();
|
||||
let mut s = session();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let changed = s.doc().unwrap().revision;
|
||||
assert!(changed > rev, "{cmd} {p}: the first run changes the view");
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(s.doc().unwrap().revision, changed, "{cmd} {p}: the same view again");
|
||||
}
|
||||
|
||||
/// Runs `cmd` with `p` once from the default proof view, which `p` leaves as it is.
|
||||
fn default_view_keeps_revision(cmd: &str, p: Value) {
|
||||
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _v = DefaultView::new();
|
||||
let mut s = session();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert_eq!(s.doc().unwrap().revision, rev, "{cmd} {p}");
|
||||
assert_eq!(vectorcraft_render::proof::view(), Default::default(), "{cmd} {p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_setup_with_nothing_changed_keeps_the_revision() {
|
||||
repeat_keeps_revision("view.proofSetup", json!({"target": "deuteranopia"}));
|
||||
repeat_keeps_revision("view.proofSetup", json!({"simulatePaper": true, "proof": true}));
|
||||
default_view_keeps_revision("view.proofSetup", json!({}));
|
||||
default_view_keeps_revision("view.proofSetup", json!({"target": "workingCmyk", "proof": false}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_colors_with_nothing_changed_keeps_the_revision() {
|
||||
repeat_keeps_revision("view.proofColors", json!({"on": true}));
|
||||
default_view_keeps_revision("view.proofColors", json!({"on": false}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overprint_preview_with_nothing_changed_keeps_the_revision() {
|
||||
repeat_keeps_revision("view.overprintPreview", json!({"on": true}));
|
||||
default_view_keeps_revision("view.overprintPreview", json!({"on": false}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separations_preview_with_nothing_changed_keeps_the_revision() {
|
||||
repeat_keeps_revision("view.separationsPreview", json!({"on": true}));
|
||||
repeat_keeps_revision("view.separationsPreview", json!({"only": "Cyan"}));
|
||||
repeat_keeps_revision("view.separationsPreview", json!({"plates": ["Magenta", "Black"]}));
|
||||
default_view_keeps_revision("view.separationsPreview", json!({"on": false}));
|
||||
}
|
||||
|
||||
/// Separations Preview's visible plates are a set: the same plates in another order, or with one
|
||||
/// named twice, leave the revision alone, and a plate named twice hides with one `toggle`.
|
||||
#[test]
|
||||
fn separations_preview_compares_the_visible_plates_as_a_set() {
|
||||
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _v = DefaultView::new();
|
||||
let mut s = session();
|
||||
let sep = |s: &mut Session, p: Value| {
|
||||
s.execute("view.separationsPreview", &p).unwrap();
|
||||
s.doc().unwrap().revision
|
||||
};
|
||||
let shown = sep(&mut s, json!({"plates": ["Magenta", "Black"]}));
|
||||
assert_eq!(sep(&mut s, json!({"plates": ["Black", "Magenta"]})), shown, "another order");
|
||||
assert_eq!(sep(&mut s, json!({"plates": ["Magenta", "Black", "Black"]})), shown, "a plate named twice");
|
||||
// Cyan hidden and shown again goes to the end of the list; every plate is shown.
|
||||
sep(&mut s, json!({"on": false}));
|
||||
sep(&mut s, json!({"on": true}));
|
||||
sep(&mut s, json!({"toggle": "Cyan"}));
|
||||
let all = sep(&mut s, json!({"toggle": "Cyan"}));
|
||||
assert_eq!(sep(&mut s, json!({"plates": ["Cyan", "Magenta", "Yellow", "Black"]})), all, "every plate in plate order");
|
||||
sep(&mut s, json!({"plates": ["Cyan", "Cyan"]}));
|
||||
let r = s.execute("view.separationsPreview", &json!({"toggle": "Cyan"})).unwrap();
|
||||
assert!(r["plates"].as_array().unwrap().iter().all(|p| p["visible"] == false), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_setup_simulates_tritanopia() {
|
||||
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
@@ -98,6 +98,69 @@ fn export_writes_the_sheet_and_rasterized_art() {
|
||||
assert!(s.execute("css.export", &json!({})).is_err(), "nothing selected: nothing to export");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_refuses_a_scope_or_path_that_is_not_a_string() {
|
||||
let mut s = session();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}));
|
||||
run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40}));
|
||||
let refused = |s: &mut Session, p: Value, key: &str| {
|
||||
let e = s.execute("css.export", &p).expect_err(&p.to_string()).to_string();
|
||||
assert_eq!(e, format!("invalid parameters for `css.export`: `{key}` must be a string"), "{p}");
|
||||
};
|
||||
for scope in [json!(5), json!(true), json!(["all"]), Value::Null] {
|
||||
refused(&mut s, json!({"scope": scope}), "scope");
|
||||
}
|
||||
for path in [json!(5), Value::Null] {
|
||||
refused(&mut s, json!({"path": path}), "path");
|
||||
refused(&mut s, json!({"path": path, "scope": "all"}), "path");
|
||||
}
|
||||
// Left out, each keeps its default: the selection (the ellipse), and the CSS as data.
|
||||
let r = run(&mut s, "css.export", json!({}));
|
||||
assert_eq!((r["rules"].clone(), r["data"].is_string()), (json!(1), true), "{r}");
|
||||
assert_eq!(run(&mut s, "css.export", json!({"scope": "all"}))["rules"], json!(2));
|
||||
let e = s.execute("css.export", &json!({"scope": "page"})).unwrap_err().to_string();
|
||||
assert!(e.contains("scope must be selection or all (got page)"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_object_refuses_malformed_ids_it_does_not_use() {
|
||||
let mut s = session();
|
||||
let rect = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "shape.ellipse", json!({"x": 60, "y": 0, "width": 40, "height": 40}));
|
||||
for (cmd, base) in [("css.generate", json!({})), ("css.export", json!({"scope": "all"}))] {
|
||||
for (key, value, says) in [
|
||||
("ids", json!("abc"), "`ids` must be an array of object ids, not \"abc\""),
|
||||
("ids", json!([1.5]), "object ids are non-negative integers, not 1.5"),
|
||||
("id", json!(-1), "object ids are non-negative integers, not -1"),
|
||||
("id", json!("$step"), "`id` is the step reference \"$step\""),
|
||||
] {
|
||||
let mut p = base.clone();
|
||||
p[key] = value;
|
||||
let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}")).to_string();
|
||||
assert!(e.contains(says), "{cmd} {p}: {e}");
|
||||
}
|
||||
// Well-formed ids are still not used: every object.
|
||||
let mut p = base.clone();
|
||||
p["ids"] = json!([rect]);
|
||||
let rules = run(&mut s, cmd, p)["rules"].clone();
|
||||
let count = rules.as_array().map_or(rules.as_u64(), |r| Some(r.len() as u64));
|
||||
assert_eq!(count, Some(2), "{cmd}: {rules}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn export_with_a_scope_that_is_not_a_string_writes_nothing() {
|
||||
let mut s = session();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}));
|
||||
let dir = std::env::temp_dir().join(format!("vc-css-scope-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("site.css");
|
||||
assert!(s.execute("css.export", &json!({"path": path.to_string_lossy(), "scope": 5})).is_err());
|
||||
assert!(!path.exists(), "no file for a refused scope");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn export_to_a_path_puts_the_pictures_next_to_the_sheet() {
|
||||
|
||||
@@ -214,26 +214,72 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() {
|
||||
let mine = format!("loaded/{}", text(&inside));
|
||||
let copied = confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": mine})));
|
||||
assert_eq!((copied["library"].as_str(), copied["copied"].as_bool()), (Some("user/Kit.vcswatches"), Some(true)), "{copied}");
|
||||
assert_eq!(copied["path"], Value::Null, "{copied}");
|
||||
assert_eq!(copied.get("path"), None, "{copied}");
|
||||
let saved = confine(roots.as_ref(), || run(&mut s, "swatch.library.save", json!({"user": true, "name": "Mine"})));
|
||||
assert_eq!((saved["library"].as_str(), saved.get("path")), (Some("user/Mine.vcswatches"), None), "{saved}");
|
||||
for reply in [&added, &copied, &saved] {
|
||||
assert!(!holds(reply, &base.join("a")) && !holds(reply, &base.join("user")), "{reply}");
|
||||
}
|
||||
// A library loaded from data under the file name keeps its id; each concealed one takes the next
|
||||
// free one, so no two rows share an id.
|
||||
// A library loaded from data under the file name keeps its id; the concealed one shown under that
|
||||
// id takes the next free one, the other keeps its id, and no two rows share an id.
|
||||
let data = run(&mut s, "swatch.library.save", json!({}))["data"].as_str().unwrap().to_string();
|
||||
run(&mut s, "swatch.library.load", json!({"data": data, "name": "Kit.vcswatches"}));
|
||||
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
|
||||
let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect();
|
||||
assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches")], "{list}");
|
||||
assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches")], "{list}");
|
||||
// Copied into the user library folder, it is still found by that id, which no other library takes.
|
||||
confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": "loaded/Kit.vcswatches"})));
|
||||
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
|
||||
let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect();
|
||||
assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)")], "{list}");
|
||||
assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)")], "{list}");
|
||||
// The person sees the folder.
|
||||
let saved = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Theirs"}));
|
||||
assert_eq!(saved["path"], json!(text(&base.join("user").join("Theirs.vcswatches"))));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Outside Windows a `\` is part of a file name: a concealed library saved at `<folder>/weird\name.gpl`
|
||||
/// is shown under its whole file name. Another platform's absolute path is still named by the part
|
||||
/// after its last `\`.
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn a_concealed_library_keeps_a_backslash_in_its_file_name() {
|
||||
let base = folder("backslash-name");
|
||||
let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
|
||||
let native = format!("loaded/{}/weird\\name.gpl", text(&base.join("a")));
|
||||
let foreign = r"loaded/C:\Users\someone\other.gpl".to_string();
|
||||
let s = Session::new();
|
||||
let mut hidden = confine(roots.as_ref(), || cmd::swatchlib::concealed_library_ids(&s, [native.clone(), foreign.clone()]));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
hidden.sort();
|
||||
let mut want = vec![(foreign, "loaded/other.gpl".to_string()), (native, "loaded/weird\\name.gpl".to_string())];
|
||||
want.sort();
|
||||
assert_eq!(hidden, want);
|
||||
}
|
||||
|
||||
/// A concealed library keeps the id a client was shown when another library with the same file name,
|
||||
/// whose full id sorts before it, is loaded later: the id still finds the first library.
|
||||
#[test]
|
||||
fn a_concealed_library_keeps_its_id_after_another_with_its_file_name_loads() {
|
||||
let base = folder("keeps-id");
|
||||
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap();
|
||||
let (first, later) = (base.join("b").join("Kit.vcswatches"), base.join("a").join("Kit.vcswatches"));
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 100, "height": 100}));
|
||||
(SWATCHES.add)(&mut s, "First");
|
||||
run(&mut s, "swatch.library.save", json!({"path": text(&first), "name": "Kit"}));
|
||||
(SWATCHES.add)(&mut s, "Second");
|
||||
run(&mut s, "swatch.library.save", json!({"path": text(&later), "name": "Kit"}));
|
||||
let shown = "loaded/Kit.vcswatches";
|
||||
let count = run(&mut s, "swatch.library.load", json!({"path": text(&first)}))["count"].clone();
|
||||
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
|
||||
assert_eq!(loaded(&list), [(json!(shown), Value::Null, count)], "{list}");
|
||||
run(&mut s, "swatch.library.load", json!({"path": text(&later)}));
|
||||
let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({})));
|
||||
let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect();
|
||||
assert_eq!(ids, [json!(shown), json!("loaded/Kit.vcswatches (2)")], "{list}");
|
||||
let theirs = run(&mut s, "swatch.library.get", json!({"library": format!("loaded/{}", text(&first))}));
|
||||
let got = confine(roots.as_ref(), || run(&mut s, "swatch.library.get", json!({"library": shown})));
|
||||
assert_eq!((&got["id"], &got["swatches"]), (&json!(shown), &theirs["swatches"]), "the first library: {got}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
@@ -728,6 +728,56 @@ fn relink_each_with_copy_copies_nothing_when_a_file_cant_be_read() {
|
||||
assert_eq!(link_path(&s, a), one);
|
||||
}
|
||||
|
||||
/// With copy, for a client whose read roots don't hold the document's folder: the reply names the
|
||||
/// copy by its file name, and a copy refused there keeps the write root and names the folder and
|
||||
/// the file by their names.
|
||||
#[test]
|
||||
fn relink_with_copy_names_the_documents_folder_by_its_name_outside_the_read_roots() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let dir = Folder::new("relink-copy-roots");
|
||||
let one = dir.file("doc/one.png");
|
||||
write(&one, &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
let a = place(&mut s, &one);
|
||||
save(&mut s, &dir.file("doc/poster.vectorcraft"));
|
||||
let x = dir.file("new/x.png");
|
||||
write(&x, &png(60, 30, BLUE));
|
||||
let (new, doc) = (Path::new(&x).parent().unwrap().to_path_buf(), Path::new(&one).parent().unwrap().to_path_buf());
|
||||
let hidden = doc.to_string_lossy().into_owned();
|
||||
let p = json!({"ids": [a.0], "path": x, "copy": true});
|
||||
|
||||
let writes_there = AutomationRoots::new(&[new.as_path()], &[doc.as_path()]).unwrap();
|
||||
let r = confine(writes_there.as_ref(), || s.execute("links.relink", &p)).unwrap();
|
||||
assert_eq!(r["copied"], json!([{"from": x, "to": "x.png"}]), "{r}");
|
||||
assert!(!r.to_string().contains(&hidden), "{r}");
|
||||
std::fs::remove_file(dir.file("doc/x.png")).unwrap();
|
||||
|
||||
let elsewhere = AutomationRoots::new(&[new.as_path()], &[new.as_path()]).unwrap();
|
||||
let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&new).unwrap()).display().to_string();
|
||||
let e = confine(elsewhere.as_ref(), || s.execute("links.relink", &p)).unwrap_err().to_string();
|
||||
assert_eq!(e, format!("doc: automation path rejected: outside the write root {root}: x.png"));
|
||||
assert!(!Path::new(&dir.file("doc/x.png")).exists());
|
||||
}
|
||||
|
||||
/// With copy, for a document whose path has no folder: outside the read roots the error does not
|
||||
/// name the path, and without roots it names it whole.
|
||||
#[test]
|
||||
fn relink_with_copy_names_a_document_path_without_a_folder_as_the_request_may_see_it() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let dir = Folder::new("relink-copy-no-folder");
|
||||
let mut s = session();
|
||||
s.doc_mut().unwrap().path = Some("/".into());
|
||||
let p = json!({"path": dir.file("x.png"), "copy": true});
|
||||
let roots = AutomationRoots::new(&[dir.0.as_path()], &[dir.0.as_path()]).unwrap();
|
||||
let e = confine(roots.as_ref(), || s.execute("links.relink", &p)).unwrap_err().to_string();
|
||||
assert_eq!(e, "invalid parameters for `links.relink`: a file or folder outside the read roots has no folder");
|
||||
let whole = s.execute("links.relink", &p).unwrap_err().to_string();
|
||||
let doc = crate::cmd::fileio::absolute_path("/");
|
||||
assert_eq!(whole, format!("invalid parameters for `links.relink`: {doc} has no folder"));
|
||||
}
|
||||
|
||||
/// A scope or paths that doesn't fit is refused, and nothing is relinked.
|
||||
#[test]
|
||||
fn relink_refuses_scopes_and_paths_that_dont_fit() {
|
||||
|
||||
@@ -291,6 +291,20 @@ fn file_new_and_artboard_new_take_the_same_largest_side() {
|
||||
assert!(err.contains(in_meters), "{p}: {err}");
|
||||
}
|
||||
assert_eq!(boards(&s).len(), 2);
|
||||
|
||||
// A side below 1 pt is kept as given, as file.new keeps it, and a size taken from such an artboard keeps it too.
|
||||
let mut s = new_doc(json!({"width": 0.5, "height": 0.25}));
|
||||
assert_eq!(boards(&s), [Rect::new(0.0, 0.0, 0.5, 0.25)]);
|
||||
s.execute("artboard.new", &json!({"x": 0, "y": 10, "width": 0.5, "height": 0.75})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 0, "y": 20})).unwrap();
|
||||
assert_eq!(boards(&s)[1..], [Rect::new(0.0, 10.0, 0.5, 10.75), Rect::new(0.0, 20.0, 0.5, 20.75)]);
|
||||
// artboard.setProps keeps such sides when it changes something else, on a locked artboard too,
|
||||
// and still raises a supplied side to 1 pt.
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "name": "Small"})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 2, "name": "Small locked"})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "height": 0.5})).unwrap();
|
||||
assert_eq!(boards(&s), [Rect::new(0.0, 0.0, 0.5, 1.0), Rect::new(0.0, 10.0, 0.5, 10.75), Rect::new(0.0, 20.0, 0.5, 20.75)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -191,6 +191,27 @@ fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Transform effect after a drop shadow, on the object or on the fill, scales and turns the
|
||||
/// shadow on the canvas; Object › Expand of the gradient fill keeps the shadow the canvas drew.
|
||||
#[test]
|
||||
fn a_shadow_before_a_transform_expands_as_the_canvas_draws_it() {
|
||||
for item in [json!(null), json!(0)] {
|
||||
let mut s = session();
|
||||
let id = gradient_rect(&mut s, Rect::new(80.0, 80.0, 120.0, 120.0), white_black(), false);
|
||||
for (effect, params) in [
|
||||
("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})),
|
||||
("distort.transform", json!({"scaleH": 200, "scaleV": 200, "rotate": 90})),
|
||||
] {
|
||||
run(&mut s, "effect.apply", json!({"effect": effect, "params": params, "ids": [id.0], "item": item}));
|
||||
}
|
||||
let before = render(&s);
|
||||
run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64}));
|
||||
let after = render(&s);
|
||||
let diff = differing(&before, &after, 64);
|
||||
assert_eq!(diff, 0, "item {item}: {diff} pixels differ");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() {
|
||||
// The object's own effect, and the fill's (the first appearance item).
|
||||
|
||||
@@ -308,6 +308,30 @@ fn the_outline_of_a_reshaped_stroke_keeps_its_drop_shadow() {
|
||||
assert_eq!(differing(&before, &render(&s), 64), 0, "the shadowed outline draws as the stroke did");
|
||||
}
|
||||
|
||||
/// A Transform effect after a drop shadow, on the object or on the stroke (brushed or not), turns
|
||||
/// and scales the shadow on the canvas; outlining the stroke keeps the shadow the canvas drew.
|
||||
#[test]
|
||||
fn a_shadow_before_a_transform_outlines_as_the_canvas_draws_it() {
|
||||
let effects = [
|
||||
("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 100})),
|
||||
("distort.transform", json!({"scaleH": 50, "scaleV": 50, "rotate": 90})),
|
||||
];
|
||||
for (item, brush) in [(Value::Null, false), (json!(1), false), (json!(1), true)] {
|
||||
// Flatten Transparency draws a brushed stroke's drop shadow differently with or without a
|
||||
// Transform effect, so the brushed stroke is checked with the other two commands.
|
||||
for (command, p) in outlining().into_iter().filter(|(command, _)| !(brush && *command == "object.flattenTransparency")) {
|
||||
let mut s = session();
|
||||
square_with(&mut s, json!({"color": "#ff0000"}), item.clone(), &effects);
|
||||
if brush {
|
||||
s.execute("brush.apply", &json!({"name": "Tapered Stroke"})).unwrap();
|
||||
}
|
||||
let before = render(&s);
|
||||
outlined_by(&mut s, command, &p);
|
||||
assert_eq!(differing(&before, &render(&s), 64), 0, "{command}, item {item}, brush {brush}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The brush art of a brushed stroke with a geometry effect follows the path the effect makes.
|
||||
#[test]
|
||||
fn a_brush_draws_along_the_path_the_stroke_effects_make() {
|
||||
|
||||
@@ -1084,6 +1084,102 @@ fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() {
|
||||
assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}");
|
||||
}
|
||||
|
||||
/// `document.inspect` and `document.json` for a confined caller: the document's file, a placed
|
||||
/// document and a linked image outside the read roots are not named by path, and the document
|
||||
/// keeps its links.
|
||||
#[test]
|
||||
fn document_replies_leave_out_paths_outside_the_read_roots() {
|
||||
let away = Folder::new("doc-lrr-away");
|
||||
let home = Folder::new("doc-lrr-home");
|
||||
let src = away.file("logo.vectorcraft");
|
||||
source(&src, 100.0, 50.0, RED, 0);
|
||||
let pic = away.file("photo.png");
|
||||
write(&pic, &crate::tests_links::png(60, 30, BLUE));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &src);
|
||||
let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap();
|
||||
assert_eq!(r["linked"], true, "{r}");
|
||||
s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Row", "values": {"Photo": pic}})).unwrap();
|
||||
let image = r["ids"][0].as_u64().unwrap();
|
||||
let poster = away.file("poster.vectorcraft");
|
||||
save(&mut s, &poster);
|
||||
// The folder Export for Screens wrote to, and a pattern of the linked image being edited: its
|
||||
// definition before editing is kept beside the tile art.
|
||||
let out = away.file("out");
|
||||
s.execute("document.exportForScreens", &json!({"folder": out, "formats": [{"format": "svg"}]})).unwrap();
|
||||
let pattern = s.execute("object.pattern.make", &json!({"ids": [image], "edit": false})).unwrap()["name"].clone();
|
||||
s.execute("object.pattern.edit", &json!({"name": pattern})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.pattern_edit.as_ref().is_some_and(|e| e.original.is_some()));
|
||||
let ask = |s: &mut Session, root: &std::path::Path, cmd: &str| {
|
||||
let roots = crate::file_access::AutomationRoots::new(&[root], &[]).unwrap().unwrap();
|
||||
crate::file_access::confine(Some(&roots), || s.execute(cmd, &json!({}))).unwrap()
|
||||
};
|
||||
let inspect = ask(&mut s, &home.0, "document.inspect");
|
||||
assert!(!inspect.to_string().contains("doc-lrr-away"), "{inspect}");
|
||||
assert!(inspect["path"].is_null() && inspect["uid"].is_u64(), "{inspect}");
|
||||
let model = ask(&mut s, &home.0, "document.json").to_string();
|
||||
assert!(!model.contains("doc-lrr-away"), "{model}");
|
||||
for name in ["logo.vectorcraft", "photo.png"] {
|
||||
assert!(model.contains(&format!("\"path\":\"{name}\"")), "{name}: {model}");
|
||||
}
|
||||
assert!(model.contains("{\"image\":\"photo.png\"}"), "{model}");
|
||||
// The document keeps its links, and a caller whose read roots hold the files sees every path.
|
||||
assert_eq!(placed(&s, id).link.path, src);
|
||||
for model in [ask(&mut s, &away.0, "document.json"), s.execute("document.json", &json!({})).unwrap()] {
|
||||
let model = model.to_string();
|
||||
for path in [&src, &pic, &out] {
|
||||
assert!(model.contains(&json!(path).to_string()), "{path}: {model}");
|
||||
}
|
||||
}
|
||||
assert_eq!(ask(&mut s, &away.0, "document.inspect")["path"], json!(poster));
|
||||
assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["path"], json!(poster));
|
||||
}
|
||||
|
||||
/// `document.json` for a confined caller: a linked image in a brush's art outside the read roots
|
||||
/// is named by its file name, a stored brush entry that does not read as a brush is left out, and a
|
||||
/// link inside them whose stored relative path leads outside them loses that relative path.
|
||||
#[test]
|
||||
fn document_json_checks_brush_art_and_relative_paths_against_the_read_roots() {
|
||||
let away = Folder::new("doc-brr-away");
|
||||
let home = Folder::new("doc-brr-home");
|
||||
let pic = away.file("photo.png");
|
||||
write(&pic, &crate::tests_links::png(60, 30, BLUE));
|
||||
let near_pic = home.file("near.png");
|
||||
write(&near_pic, &crate::tests_links::png(20, 10, RED));
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({"path": pic, "at": [50, 50]})).unwrap();
|
||||
assert_eq!(r["linked"], true, "{r}");
|
||||
let image = r["ids"][0].as_u64().unwrap();
|
||||
let r = s.execute("file.place", &json!({"path": near_pic, "at": [150, 50]})).unwrap();
|
||||
assert_eq!(r["linked"], true, "{r}");
|
||||
s.execute("brush.new", &json!({"type": "art", "name": "Photo Band", "ids": [image]})).unwrap();
|
||||
save(&mut s, &home.file("poster.vectorcraft"));
|
||||
{
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let lib = d.unknown.get_mut(vectorcraft_brush::DOC_KEY).and_then(Value::as_array_mut).unwrap();
|
||||
lib.push(json!({"name": "Broken Band", "type": "unknown kind", "note": pic}));
|
||||
d.update_links(|l| {
|
||||
if l.name() == "near.png" {
|
||||
l.relative = Some("../doc-brr-away/near.png".into());
|
||||
}
|
||||
});
|
||||
}
|
||||
let unconfined = s.execute("document.json", &json!({})).unwrap().to_string();
|
||||
for part in [json!(pic).to_string(), "Broken Band".into(), "../doc-brr-away/near.png".into()] {
|
||||
assert!(unconfined.contains(&part), "{part}: {unconfined}");
|
||||
}
|
||||
let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap();
|
||||
let model = crate::file_access::confine(Some(&roots), || s.execute("document.json", &json!({}))).unwrap();
|
||||
let text = model.to_string();
|
||||
assert!(!text.contains("doc-brr-away") && !text.contains("Broken Band"), "{text}");
|
||||
assert!(text.contains("\"path\":\"photo.png\"") && text.contains("Photo Band"), "{text}");
|
||||
assert!(text.contains(&json!(near_pic).to_string()), "{text}");
|
||||
// The document keeps its brushes and links.
|
||||
let stored = s.doc().unwrap().doc.unknown.get(vectorcraft_brush::DOC_KEY).unwrap().to_string();
|
||||
assert!(stored.contains("Broken Band") && stored.contains(&json!(pic).to_string()), "{stored}");
|
||||
}
|
||||
|
||||
/// Save As reads its defaults from the catalog; using them must retain current editable links.
|
||||
#[test]
|
||||
fn native_save_catalog_defaults_keep_placed_documents_editable() {
|
||||
|
||||
@@ -452,6 +452,60 @@ fn a_painted_effect_on_a_fill_and_refused_results() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fill's Drop Shadow before a painted effect that returns two fills, and a 200% Transform effect
|
||||
/// after it: the shadow of each returned fill follows the Transform effect. The square the effect
|
||||
/// returns spans 30 to 70 pt once scaled, so both half-opaque shadows, 20 pt to the right, cover
|
||||
/// 85 pt as they cover 75 pt.
|
||||
#[test]
|
||||
fn a_shadow_on_each_fill_a_painted_effect_returns_follows_the_transform_after_it() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 10.0, "#ff0000");
|
||||
let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00"), solid("#0000ff")]}]}).to_string();
|
||||
install_wat(&mut s, &tpl::returning(&painting("test.engine.twofills", true), &out));
|
||||
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
|
||||
for (effect, params) in [
|
||||
("stylize.dropShadow", json!({"x": 10, "y": 0, "blur": 0, "opacity": 50})),
|
||||
("plugin.test.engine.twofills", json!({})),
|
||||
("distort.transform", json!({"scaleH": 200, "scaleV": 200})),
|
||||
] {
|
||||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "params": params, "item": fill}));
|
||||
}
|
||||
let img = drawn(&s);
|
||||
let (near, far) = (img.pixel(75, 50), img.pixel(85, 50));
|
||||
assert_ne!(near, WHITE, "the shadows reach past the square");
|
||||
assert_eq!(far, near, "both shadows at 20 pt");
|
||||
}
|
||||
|
||||
/// A Transform effect with Scale Strokes & Effects on after a painted effect on a fill leaves the
|
||||
/// Drop Shadow after it as it is on every paint the piece is drawn with.
|
||||
#[test]
|
||||
fn a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 10.0, "#ff0000");
|
||||
let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00"), solid("#0000ff")]}]}).to_string();
|
||||
install_wat(&mut s, &tpl::returning(&painting("test.engine.twopaints", true), &two));
|
||||
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
|
||||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.twopaints", "item": fill}));
|
||||
let mut n = node(&s, id);
|
||||
let later = [
|
||||
vectorcraft_doc::Effect { id: "distort.transform".into(), params: json!({"scaleH": 200, "scaleV": 200, "strokes": true}), visible: true },
|
||||
vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: json!({"x": 10.0, "y": 0.0, "blur": 0.0}), visible: true },
|
||||
];
|
||||
n.appearance.items[fill].effects_mut().extend(later);
|
||||
let art = vectorcraft_render::effects::reshape(Some(&s.doc().unwrap().doc), &n, None).unwrap();
|
||||
let mut offsets = vec![];
|
||||
art.walk(&mut |m| {
|
||||
for item in &m.appearance.items {
|
||||
for r in vectorcraft_render::effects::raster_effects(item.effects()) {
|
||||
if let vectorcraft_render::effects::RasterFx::DropShadow { dx, dy, .. } = r {
|
||||
offsets.push((dx, dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(offsets, [(10.0, 0.0), (10.0, 0.0)], "one shadow on each of the piece's two fills");
|
||||
}
|
||||
|
||||
/// A result refused for a swatch the document doesn't have stays cached: once the document has the
|
||||
/// swatch, the next check of that result clears the error.
|
||||
#[test]
|
||||
|
||||
@@ -1179,6 +1179,31 @@ fn a_keeps_entry_in_a_running_apps_area_keeps_the_copies() {
|
||||
assert_eq!(offered(&mut b).len(), 2);
|
||||
}
|
||||
|
||||
/// An entry `<area>/keeps-<left area>` in a crashed app's area keeps nothing while restore or
|
||||
/// discard holds that area: both crashed apps' copies are restored, and discarded.
|
||||
#[test]
|
||||
fn a_keeps_entry_in_a_crashed_apps_area_held_by_the_same_command_keeps_nothing() {
|
||||
let crashed = |store: &Arc<MemoryStore>| {
|
||||
let mut s = session_in(store);
|
||||
s.execute("file.recovery.save", &json!({})).unwrap();
|
||||
s.recovery.own_area().unwrap().to_string()
|
||||
};
|
||||
for cmd in ["file.recovery.restore", "file.recovery.discard"] {
|
||||
let store = Arc::new(MemoryStore::default());
|
||||
let a = crashed(&store);
|
||||
let c = crashed(&store);
|
||||
assert_ne!(a, c);
|
||||
// The area that sorts first, which the command holds first, keeps the other one's copies.
|
||||
let (first, second) = if a < c { (a, c) } else { (c, a) };
|
||||
store.write(&format!("{first}/keeps-{second}"), b"").unwrap();
|
||||
let mut b = relaunch(&store);
|
||||
assert_eq!(offered(&mut b).len(), 2);
|
||||
let r = b.execute(cmd, &json!({})).unwrap();
|
||||
let done = if cmd == "file.recovery.restore" { restored(r) } else { r["discarded"].as_array().unwrap().len() };
|
||||
assert_eq!(done, 2, "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry `<area>/keeps-<left area>` in a folder whose lock file can't be opened keeps nothing.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
|
||||
@@ -84,6 +84,55 @@ fn a_confined_save_leaves_out_paths_outside_the_read_root() {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
/// A save without a path, and Revert, refused because the document's own file is outside a confined
|
||||
/// client's roots: the error keeps the root and the reason and names the file by its file name, also
|
||||
/// for the files a save writes beside it. A path the client gives stays whole in the error.
|
||||
#[test]
|
||||
fn pathless_save_rejection_hides_document_folder() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let d = dir("roots-error");
|
||||
let (inside, outside) = (d.join("in"), d.join("kept-out"));
|
||||
for f in [&inside, &outside] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
|
||||
rect(&mut s);
|
||||
let own = path(&outside, "doc.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": own})).unwrap();
|
||||
rect(&mut s);
|
||||
let roots = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap();
|
||||
let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&inside).unwrap()).display().to_string();
|
||||
let hidden = outside.to_string_lossy().into_owned();
|
||||
let mut refused = |id: &str, p: Value| confine(roots.as_ref(), || s.execute(id, &p)).unwrap_err().to_string();
|
||||
|
||||
let e = refused("document.save", json!({}));
|
||||
assert_eq!(e, format!("automation path rejected: outside the write root {root}: doc.vectorcraft"));
|
||||
let e = refused("document.save", json!({"separateArtboards": true}));
|
||||
assert!(e.starts_with(&format!("automation path rejected: outside the write root {root}: ")) && !e.contains(&hidden), "{e}");
|
||||
let e = refused("file.revert", json!({}));
|
||||
assert_eq!(e, format!("automation path rejected: outside the read root {root}: doc.vectorcraft"));
|
||||
// Given by the client: whole.
|
||||
let e = refused("document.save", json!({"path": own}));
|
||||
assert_eq!(e, format!("automation path rejected: outside the write root {root}: {own}"));
|
||||
// Without read roots, a write refusal still names the file by its file name.
|
||||
let write_only = AutomationRoots::new(&[], &[inside.as_path()]).unwrap();
|
||||
let e = confine(write_only.as_ref(), || s.execute("document.save", &json!({}))).unwrap_err().to_string();
|
||||
assert_eq!(e, format!("automation path rejected: outside the write root {root}: doc.vectorcraft"));
|
||||
assert!(s.doc().unwrap().is_dirty(), "nothing was saved");
|
||||
// Read with losses from its own file: the refusal to write over it names the file by its file name.
|
||||
let st = s.doc_mut().unwrap();
|
||||
st.imported_from = Some(own.clone());
|
||||
st.import_losses = vec!["hidden text".into()];
|
||||
let e = confine(roots.as_ref(), || s.execute("document.save", &json!({}))).unwrap_err().to_string();
|
||||
assert!(e.starts_with("invalid parameters for `document.save`: doc.vectorcraft is the file this document was read from"), "{e}");
|
||||
assert!(!e.contains(&hidden), "{e}");
|
||||
let e = confine(roots.as_ref(), || s.execute("document.save", &json!({"path": own}))).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("{own} is the file this document was read from")), "{e}");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_svg_path_writes_svg_and_save_writes_it_back() {
|
||||
let d = dir("svg");
|
||||
@@ -569,6 +618,30 @@ fn eps_save_with_use_artboards_and_a_named_artboard_reports_it_as_the_range() {
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_save_with_use_artboards_refuses_to_replace_the_file_read_from_before_writing_any_file() {
|
||||
let d = dir("eps-artboards-lossy-sibling");
|
||||
let eps_path = path(&d, "Art.eps");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
|
||||
rect(&mut s);
|
||||
let options = json!({"useArtboards": true, "previewFormat": "none", "thumbnails": false});
|
||||
let r = s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap();
|
||||
let sibling = r["files"][1].as_str().unwrap().to_string();
|
||||
let original = std::fs::read(&sibling).unwrap();
|
||||
std::fs::remove_file(&eps_path).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
st.imported_from = Some(sibling.clone());
|
||||
st.import_losses.push("test import omitted artwork".into());
|
||||
let err = s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap_err().to_string();
|
||||
assert!(err.contains("acknowledgeLoss") && err.contains(&sibling), "{err}");
|
||||
assert_eq!(std::fs::read(&sibling).unwrap(), original);
|
||||
assert!(!std::path::Path::new(&eps_path).exists(), "no file of the set is written when one is refused");
|
||||
s.execute("document.save", &json!({"path": eps_path, "options": options, "acknowledgeLoss": true})).unwrap();
|
||||
assert!(std::path::Path::new(&eps_path).exists());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_save_with_use_artboards_writes_the_central_file_and_one_per_artboard() {
|
||||
let d = dir("eps-artboards");
|
||||
|
||||
@@ -230,6 +230,42 @@ fn an_engine_keeps_the_newest_places_within_its_budget() {
|
||||
assert_eq!((d_place.0, d_place.1, d_place.2), (None, 0, 0), "a map larger than the budget is not kept");
|
||||
}
|
||||
|
||||
/// A run of a named engine whose script does not compile defines no code, so it keeps no place
|
||||
/// and pushes out none of the places kept before. An included file without a preprocessor line is
|
||||
/// first parsed with the script it joins, so its syntax error ends the run as it starts.
|
||||
#[test]
|
||||
fn a_run_that_does_not_compile_keeps_no_place() {
|
||||
let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-uncompiled-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
// A kept place counts the names of the files the run read: four places of the failed run's size
|
||||
// would not fit in the budget.
|
||||
let mut text = String::from("#targetengine e\n");
|
||||
for k in 0..10 {
|
||||
std::fs::write(base.join(format!("ok{k}.jsxinc")), "var ok = 1;").unwrap();
|
||||
text.push_str(&format!("#include \"ok{k}.jsxinc\"\n"));
|
||||
}
|
||||
std::fs::write(base.join("bad.jsxinc"), "var = ;").unwrap();
|
||||
text.push_str("#include \"bad.jsxinc\"");
|
||||
let bad = base.join("bad.jsx");
|
||||
std::fs::write(&bad, text).unwrap();
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "#targetengine e\nvar x = 1;").unwrap();
|
||||
TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(3000)));
|
||||
run(&mut s, "#targetengine e\n\n\nfunction c() { throw new Error('c'); }").unwrap();
|
||||
let mut kinds = vec![];
|
||||
for _ in 0..5 {
|
||||
assert!(s.execute("script.run", &json!({"path": bad.to_string_lossy()})).is_err());
|
||||
kinds.push(s.script_report().unwrap()["kind"].clone());
|
||||
}
|
||||
assert!(run(&mut s, "#targetengine e\nc();").is_err());
|
||||
let c_place = place(&s);
|
||||
TEST_KEPT_MAP_BYTES.with(|b| b.set(None));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
assert!(kinds.iter().all(|k| k == "syntax"), "{kinds:?}");
|
||||
assert_eq!((c_place.0.as_deref(), c_place.1), (Some("t.js"), 4));
|
||||
}
|
||||
|
||||
/// A `$.evalFile` an earlier run kept reads files the way the run that calls it does: relative to
|
||||
/// that run's script, and as `$.evalFile` does in a run given as text.
|
||||
#[test]
|
||||
|
||||
@@ -311,7 +311,9 @@ fn the_automation_read_root_keeps_an_agent_out_of_the_scripts_folder() {
|
||||
s.attach_scripts_dir(root.clone());
|
||||
let roots = crate::file_access::AutomationRoots::new(&[&work], &[]).unwrap().unwrap();
|
||||
let e = crate::file_access::confine(Some(&roots), || s.execute("script.listInstalled", &json!({}))).unwrap_err();
|
||||
assert!(e.to_string().contains("outside the read root"), "{e}");
|
||||
// The Scripts folder is the app's: the error keeps the root and names the folder by its name.
|
||||
let shown_root = crate::cmd::findfiles::plain(std::fs::canonicalize(&work).unwrap()).display().to_string();
|
||||
assert_eq!(e.to_string(), format!("automation path rejected: outside the read root {shown_root}: Scripts"));
|
||||
let e = crate::file_access::confine(Some(&roots), || s.installed_script(&script.to_string_lossy())).unwrap_err();
|
||||
assert!(e.to_string().contains("outside the read root"), "{e}");
|
||||
// A read root that holds the folder lets both through.
|
||||
|
||||
@@ -87,6 +87,50 @@ fn set_range_style_splits_runs() {
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "size": -1})).is_err());
|
||||
}
|
||||
|
||||
/// `text.create`, `text.createInPath`, `text.setStyle` and `text.setRangeStyle` refuse a size
|
||||
/// outside 0.1 to 1296 pt with an error naming the range, and change nothing; both ends are taken.
|
||||
#[test]
|
||||
fn a_type_size_outside_its_range_is_refused() {
|
||||
let mut s = session();
|
||||
let message = "`size` must be from 0.1 to 1296";
|
||||
let state = |s: &Session| (s.doc().unwrap().doc.node_count(), s.doc().unwrap().history.undo.len());
|
||||
let before = state(&s);
|
||||
for size in [2835.0, 1296.5, 0.05, 0.0, -1.0] {
|
||||
let e = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Big", "size": size})).unwrap_err().to_string();
|
||||
assert!(e.contains(message), "{size}: {e}");
|
||||
}
|
||||
assert_eq!(state(&s), before, "no type is made");
|
||||
let big = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Big", "size": 1296})).unwrap();
|
||||
let big = NodeId(big["id"].as_u64().unwrap());
|
||||
assert_eq!(obj(&s, big).runs[0].style.size, 1296.0);
|
||||
let small = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "small", "size": 0.1})).unwrap();
|
||||
assert_eq!(obj(&s, NodeId(small["id"].as_u64().unwrap())).runs[0].style.size, 0.1);
|
||||
let path = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].clone();
|
||||
let before = state(&s);
|
||||
let e = s.execute("text.createInPath", &json!({"path": path, "text": "In", "size": 2835})).unwrap_err().to_string();
|
||||
assert!(e.contains(message), "{e}");
|
||||
assert_eq!(state(&s), before);
|
||||
s.execute("text.createInPath", &json!({"path": path, "text": "In", "size": 1296})).unwrap();
|
||||
let id = text(&mut s, "Hello");
|
||||
let before = state(&s);
|
||||
for (cmd, params) in [
|
||||
("text.setStyle", json!({"ids": [id.0], "size": 2835})),
|
||||
("text.setStyle", json!({"ids": [id.0], "size": "1300 pt"})),
|
||||
("text.setStyle", json!({"ids": [id.0], "size": 0.05})),
|
||||
("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "size": 2835})),
|
||||
("text.setRangeStyle", json!({"id": id.0, "size": 0})),
|
||||
] {
|
||||
let e = s.execute(cmd, ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains(message), "{cmd} {params}: {e}");
|
||||
}
|
||||
assert_eq!(state(&s), before, "nothing changes");
|
||||
assert_eq!(obj(&s, id).runs[0].style.size, 20.0);
|
||||
s.execute("text.setStyle", &json!({"ids": [id.0], "size": 1296})).unwrap();
|
||||
assert_eq!(obj(&s, id).runs[0].style.size, 1296.0);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "size": 0.1})).unwrap();
|
||||
assert_eq!(obj(&s, id).runs[0].style.size, 0.1);
|
||||
}
|
||||
|
||||
/// `featureChanges` turns features on and off on top of each run's own and keeps the rest, in
|
||||
/// `text.setStyle` and `text.setRangeStyle`, in one undo step; an unknown tag is an error.
|
||||
#[test]
|
||||
@@ -524,6 +568,7 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() {
|
||||
("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"),
|
||||
("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"),
|
||||
("text.setFormat", json!({"ids": [id.0], "koreanLineBreakAnywhere": 1}), "koreanLineBreakAnywhere"),
|
||||
("text.setFormat", json!({"ids": [id.0], "bunriKinshi": "on"}), "bunriKinshi"),
|
||||
("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"),
|
||||
("type.opticalMarginAlignment", json!({"on": "true"}), "on"),
|
||||
("type.systemLayout", json!({"on": "true"}), "on"),
|
||||
|
||||
@@ -2916,3 +2916,191 @@ fn applying_a_graph_row_again_records_no_undo_step() {
|
||||
s.execute("dataset.select", &json!({"name": "B"})).unwrap();
|
||||
assert_eq!(super::history(&s), (before.0 + 1, 0, true));
|
||||
}
|
||||
|
||||
/// The document's SVG with the options `svg` → (its text, the export's warnings).
|
||||
fn svg_export(s: &mut Session, svg: Value) -> (String, Vec<Value>) {
|
||||
let r = s.execute("document.export", &json!({"format": "svg", "svg": svg})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
(String::from_utf8(bytes).unwrap(), r["warnings"].as_array().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// A new session with `svg` opened as `name` → (the session, the open's warnings).
|
||||
fn svg_opened(svg: &str, name: &str) -> (Session, Vec<Value>) {
|
||||
let mut o = Session::new();
|
||||
let r = o.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
|
||||
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
(o, warnings)
|
||||
}
|
||||
|
||||
/// Include Variable Data writes the text variables, the data sets' text values and each text
|
||||
/// variable's bound type object into the SVG, and opening the SVG reads them back: the type
|
||||
/// objects are bound again, so a data set changes them. Variables of other kinds are left out with
|
||||
/// a warning. Unnamed objects get ids with Minimal ids, minified files, unique ids and style
|
||||
/// entities alike, and Load Variable Library reads the same file.
|
||||
#[test]
|
||||
fn an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
let u = text(&mut s, "9.99");
|
||||
// A fill of its appearance writes the type in a group with the fill's path.
|
||||
s.execute("appearance.addFill", &json!({"ids": [u.0]})).unwrap();
|
||||
let r = rect(&mut s);
|
||||
for (name, kind) in [("Name", "text"), ("Shown", "visibility"), ("Price", "text")] {
|
||||
s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap();
|
||||
}
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Price", "ids": [u.0]})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Shown", "ids": [r.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "A & <B>", "values": {"Name": "Bob", "Price": "1.00\nper pound & tax\r", "Shown": false}})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Second", "values": {"Name": ""}})).unwrap();
|
||||
let before = s.doc().unwrap().doc.variables.clone();
|
||||
let text_only: Vec<vectorcraft_doc::DataSet> = before
|
||||
.datasets
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let mut d = d.clone();
|
||||
d.values.retain(|k, _| k != "Shown");
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
|
||||
for opts in [
|
||||
json!({"variableData": true}),
|
||||
json!({"variableData": true, "objectIds": "minimal", "minify": true}),
|
||||
json!({"variableData": true, "objectIds": "unique"}),
|
||||
json!({"variableData": true, "styling": "entities", "encoding": "latin1"}),
|
||||
] {
|
||||
let (svg, warnings) = svg_export(&mut s, opts.clone());
|
||||
assert!(warnings.contains(&json!("Variables that aren't text variables were left out of the SVG's variable data")), "{opts}: {warnings:?}");
|
||||
assert_eq!(svg.matches("docRef=\"id('").count(), 2, "{opts}: {svg}");
|
||||
assert_eq!(s.doc().unwrap().doc.variables, before, "{opts}: exporting changes nothing");
|
||||
|
||||
let (mut o, warnings) = svg_opened(&svg, "vars.svg");
|
||||
assert!(warnings.is_empty(), "{opts}: {warnings:?}");
|
||||
let vars = o.doc().unwrap().doc.variables.clone();
|
||||
assert_eq!(vars.variables.iter().map(|v| (v.name.as_str(), v.kind.label())).collect::<Vec<_>>(), [("Name", "text"), ("Price", "text")]);
|
||||
assert_eq!(vars.datasets, text_only, "{opts}");
|
||||
assert_eq!(vars.active_dataset, None);
|
||||
let bound = |name: &str| {
|
||||
let ids = vars.objects_of(name);
|
||||
assert_eq!(ids.len(), 1, "{opts}: {name}");
|
||||
ids[0]
|
||||
};
|
||||
let (name, price) = (bound("Name"), bound("Price"));
|
||||
assert_eq!((plain(&o, name), plain(&o, price)), ("Alice".into(), "9.99".into()), "{opts}: the art stays as the file draws it");
|
||||
let v = o.execute("dataset.select", &json!({"name": "A & <B>"})).unwrap();
|
||||
assert_eq!(v["applied"], json!(2), "{opts}: {v}");
|
||||
assert_eq!(plain(&o, name), "Bob");
|
||||
|
||||
let mut lib = session();
|
||||
lib.execute("variable.loadLibrary", &json!({"xml": svg})).unwrap();
|
||||
assert_eq!(lib.doc().unwrap().doc.variables.datasets, text_only, "{opts}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Without Include Variable Data the SVG holds no variable data, and a document without variables
|
||||
/// writes none with it on. Outlined type is written unbound, characters XML can't hold are left
|
||||
/// out of the values, and variable data past the 8 MiB or 2,000,000-node cap is left out, each
|
||||
/// with a warning; past 256 text variables or 1,000 data sets the rest are left out with a
|
||||
/// warning. Look-alike text in comments and other markup is skipped when the file opens. A file
|
||||
/// whose variable data can't be read opens its art with a warning, and so does one whose
|
||||
/// namespace entities would grow the library past its cap.
|
||||
#[test]
|
||||
fn an_svg_without_readable_variable_data_opens_its_art() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
let (plain_svg, _) = svg_export(&mut s, json!({}));
|
||||
assert!(!plain_svg.contains("variableSets"));
|
||||
let (none, _) = svg_export(&mut s, json!({"variableData": true}));
|
||||
assert!(!none.contains("variableSets"), "no variables, no variable data");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
let (off, _) = svg_export(&mut s, json!({}));
|
||||
assert!(!off.contains("variableSets"), "off by default");
|
||||
// Outlined type opens as art: the variable is written unbound, and the values lose the
|
||||
// characters XML can't hold, each with a warning.
|
||||
s.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\u{b}b\u{1}c"}})).unwrap();
|
||||
let (outlined, warnings) = svg_export(&mut s, json!({"variableData": true, "outlineText": true}));
|
||||
assert!(outlined.contains("<variable varName=\"Name\"") && !outlined.contains("docRef"), "{outlined}");
|
||||
for w in [
|
||||
"Type written as outlines opens as art: its text variables were written unbound in the SVG's variable data",
|
||||
"Characters XML can't hold were left out of data set values in the SVG's variable data",
|
||||
] {
|
||||
assert!(warnings.contains(&json!(w)), "{warnings:?}");
|
||||
}
|
||||
let (o, _) = svg_opened(&outlined, "outlined.svg");
|
||||
let vars = &o.doc().unwrap().doc.variables;
|
||||
assert!(vars.bindings.is_empty() && vars.variables.len() == 1, "{vars:?}");
|
||||
assert_eq!(vars.datasets[0].values["Name"], vectorcraft_doc::DataValue::Text("a bc".into()));
|
||||
// Variable data larger than Load Variable Library reads is left out with a warning.
|
||||
s.execute("dataset.new", &json!({"name": "Huge", "values": {"Name": "x".repeat(8 << 20)}})).unwrap();
|
||||
let (huge, warnings) = svg_export(&mut s, json!({"variableData": true}));
|
||||
assert!(!huge.contains("variableSets"));
|
||||
assert!(warnings.contains(&json!("The variable data is larger than 8 MiB, which Load Variable Library reads, and was left out of the SVG")));
|
||||
|
||||
let head = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\">";
|
||||
let art = "<rect x=\"1\" y=\"1\" width=\"5\" height=\"5\"/></svg>";
|
||||
let untraited = format!("{head}<variableSets><variableSet><variables><variable varName=\"A\"/></variables></variableSet></variableSets>{art}");
|
||||
let refs = "&ns_big;".repeat(40_000);
|
||||
let big = "x".repeat(250);
|
||||
let growing = format!(
|
||||
"<!DOCTYPE svg [<!ENTITY ns_big \"{big}\">]>{head}<variableSets><variableSet><variables><variable varName=\"A\" trait=\"textcontent\" category=\"{refs}\"/></variables></variableSet></variableSets>{art}"
|
||||
);
|
||||
// Variable data under 8 MiB with more XML nodes than Load Variable Library reads (a million
|
||||
// one-character paragraphs, minified) is left out with a warning.
|
||||
let mut lines = session();
|
||||
lines.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
lines.execute("dataset.new", &json!({"name": "Row", "values": {"Name": "a\n".repeat(1_000_000)}})).unwrap();
|
||||
let (dense, warnings) = svg_export(&mut lines, json!({"variableData": true, "minify": true}));
|
||||
assert!(!dense.contains("variableSets"));
|
||||
assert!(
|
||||
warnings.contains(&json!(
|
||||
"The variable data holds more than 2,000,000 XML nodes, which Load Variable Library reads, and was left out of the SVG"
|
||||
)),
|
||||
"{warnings:?}"
|
||||
);
|
||||
|
||||
// More text variables and data sets than Load Variable Library reads: the first 256 and the
|
||||
// first 1,000 are written, each with a warning, and the file opens with them.
|
||||
let mut many = session();
|
||||
{
|
||||
let vars = &mut std::sync::Arc::make_mut(&mut many.doc_mut().unwrap().doc).variables;
|
||||
vars.variables = (0..300).map(|i| vectorcraft_doc::Variable { name: format!("V{i}"), kind: vectorcraft_doc::VariableKind::Text }).collect();
|
||||
vars.datasets = (0..1_100)
|
||||
.map(|i| vectorcraft_doc::DataSet {
|
||||
name: format!("D{i}"),
|
||||
values: [("V0".to_string(), vectorcraft_doc::DataValue::Text(format!("{i}")))].into_iter().collect(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let (capped, warnings) = svg_export(&mut many, json!({"variableData": true}));
|
||||
for w in [
|
||||
"Text variables past the first 256, which Load Variable Library reads, were left out of the SVG's variable data",
|
||||
"Data sets past the first 1,000, which Load Variable Library reads, were left out of the SVG's variable data",
|
||||
] {
|
||||
assert!(warnings.contains(&json!(w)), "{warnings:?}");
|
||||
}
|
||||
let (o, warnings) = svg_opened(&capped, "many.svg");
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let vars = &o.doc().unwrap().doc.variables;
|
||||
assert_eq!((vars.variables.len(), vars.datasets.len()), (256, 1_000));
|
||||
assert_eq!(vars.datasets[999].values["V0"], vectorcraft_doc::DataValue::Text("999".into()));
|
||||
|
||||
// Text that only looks like variable data, in a comment, a CDATA section, a processing
|
||||
// instruction or the document type declaration, is skipped for the element itself.
|
||||
let library = "<variableSets><variableSet><variables><variable varName=\"A\" trait=\"textcontent\"/></variables></variableSet></variableSets>";
|
||||
let decoys = format!(
|
||||
"<!DOCTYPE svg [<!-- > <variableSets> -->]>{head}<!-- a > <variableSets> --><metadata><![CDATA[ a > <variableSets> ]]></metadata><?pi a > <variableSets> ?>{library}<!-- </variableSets> -->{art}"
|
||||
);
|
||||
let (o, warnings) = svg_opened(&decoys, "decoys.svg");
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(o.doc().unwrap().doc.variables.variables.len(), 1);
|
||||
|
||||
for (svg, why) in [(untraited, "variable `A` has no `trait`"), (growing, "entities expand to more than")] {
|
||||
let (o, warnings) = svg_opened(&svg, "broken.svg");
|
||||
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("variable data wasn't read") && w.contains(why))), "{warnings:?}");
|
||||
let d = &o.doc().unwrap().doc;
|
||||
assert!(d.variables.variables.is_empty());
|
||||
assert_eq!(vectorcraft_testkit::svg_shapes(&vectorcraft_svg::export(d, &Default::default())), 1, "the art opens");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1090,22 +1090,51 @@ impl Session {
|
||||
self.tool.resizing()
|
||||
}
|
||||
|
||||
/// With Real-time Drawing off, how far a Selection-tool move drag has carried the selection
|
||||
/// so far: the document moves on release, and the bounding box shows at this offset meanwhile.
|
||||
pub fn deferred_move(&self) -> Option<vectorcraft_geom::Vec2> {
|
||||
if self.tool.id() != "selection" || !self.tool.transforming() {
|
||||
/// Does the canvas draw the bounding box around a shape being drawn: the active tool is drawing
|
||||
/// a new shape whose box it shows once drawn (see `Tool::drawing_boxed`), and General › Show
|
||||
/// Bounding Box While Moving and Drawing is on?
|
||||
pub fn draws_new_shape_box(&self) -> bool {
|
||||
self.prefs.bounding_box_while_moving_and_drawing && self.tool.drawing_boxed()
|
||||
}
|
||||
|
||||
/// Has the interaction no preview applied to the document: none has run yet, the last one failed,
|
||||
/// or Real-time Drawing is off and the document is still the interaction's snapshot?
|
||||
pub fn interaction_unapplied(&self) -> bool {
|
||||
self.active().is_some_and(|d| d.interaction.as_ref().is_some_and(|it| it.preview.is_none() || std::sync::Arc::ptr_eq(&it.doc, &d.doc)))
|
||||
}
|
||||
|
||||
/// The bounding box of the selection a move drag is carrying (an Alt-drag copy included), where
|
||||
/// the drag has carried it: the box of the selection at the drag's start, measured once per
|
||||
/// drag, offset by the move of the latest preview. With General › Show Bounding Box While
|
||||
/// Moving and Drawing on, for every tool that moves the selection; off, only for the Selection
|
||||
/// tool with Real-time Drawing off, where the document moves on release. `None` for any other
|
||||
/// drag, and before a preview has moved the selection.
|
||||
pub fn move_drag_box(&self) -> Option<vectorcraft_doc::OrientedBox> {
|
||||
if !self.tool.moving() {
|
||||
return None;
|
||||
}
|
||||
let st = self.active()?;
|
||||
let it = st.interaction.as_ref()?;
|
||||
let deferred = std::sync::Arc::ptr_eq(&it.doc, &st.doc);
|
||||
if !self.prefs.bounding_box_while_moving_and_drawing && !(self.tool.id() == "selection" && deferred) {
|
||||
return None;
|
||||
}
|
||||
let it = self.active()?.interaction.as_ref()?;
|
||||
if !matches!(it.label.as_str(), "Move" | "Copy") {
|
||||
return None;
|
||||
}
|
||||
let (cmd, p) = self.unapplied_interaction_preview()?;
|
||||
let (cmd, p) = it.preview.as_ref()?;
|
||||
if cmd != "object.transform" || p.get("objects").and_then(Value::as_bool) == Some(false) {
|
||||
return None;
|
||||
}
|
||||
let [a, b, c, d, e, f] = crate::cmd::matrix_param(&p, "matrix")?.as_coeffs();
|
||||
(a == 1.0 && b == 0.0 && c == 0.0 && d == 1.0).then(|| vectorcraft_geom::Vec2::new(e, f))
|
||||
let [a, b, c, d, e, f] = crate::cmd::matrix_param(p, "matrix")?.as_coeffs();
|
||||
if !(a == 1.0 && b == 0.0 && c == 0.0 && d == 1.0) {
|
||||
return None;
|
||||
}
|
||||
let measure = self.bounds_measure();
|
||||
let start =
|
||||
(*it.move_box.get_or_init(|| vectorcraft_render::effects::preview::oriented_bounds_with(&it.doc, &it.selection.objects, measure)))?;
|
||||
let moved = start.to_local(Point::new(e, f)).to_vec2();
|
||||
Some(vectorcraft_doc::OrientedBox { rect: start.rect + moved, ..start })
|
||||
}
|
||||
|
||||
/// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)?
|
||||
@@ -1438,23 +1467,30 @@ impl Session {
|
||||
}
|
||||
|
||||
/// With Real-time Drawing off, the selection's transform box ([`Session::transform_box`]) as the
|
||||
/// pending preview of a handle resize leaves it, measured once per preview step; `None` when no
|
||||
/// such preview is pending, or when the preview fails.
|
||||
pub fn deferred_transform_box(&mut self) -> Option<vectorcraft_doc::OrientedBox> {
|
||||
if !self.tool.resizing() {
|
||||
/// pending preview of a handle resize or of a shape being drawn leaves it, measured once per
|
||||
/// preview step, with the layer color of the first object it measures; `None` when no such
|
||||
/// preview is pending, or when the preview fails.
|
||||
pub fn deferred_transform_box(&mut self) -> Option<(vectorcraft_doc::OrientedBox, [u8; 3])> {
|
||||
if !self.measures_deferred_box() {
|
||||
return None;
|
||||
}
|
||||
let (cmd, p) = self.unapplied_interaction_preview()?;
|
||||
self.deferred_feedback(&cmd, &p)?.transform_box
|
||||
}
|
||||
|
||||
/// Does the canvas draw the bounding box of what the pending preview leaves: during a handle
|
||||
/// resize, or while a shape is drawn by a tool that shows its box ([`Session::draws_new_shape_box`])?
|
||||
fn measures_deferred_box(&self) -> bool {
|
||||
self.tool.resizing() || self.draws_new_shape_box()
|
||||
}
|
||||
|
||||
fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec<Overlay> {
|
||||
self.deferred_feedback(cmd, p).map(|f| f.overlays.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take
|
||||
/// outlines from the objects it touched (and, during a handle resize, their transform box), then
|
||||
/// put the document back at the interaction snapshot.
|
||||
/// outlines from the objects it touched (and, during a handle resize or while a shape is drawn,
|
||||
/// their transform box), then put the document back at the interaction snapshot.
|
||||
fn deferred_feedback(&mut self, cmd: &str, p: &Value) -> Option<&DeferredFeedback> {
|
||||
if !self.deferred_overlay_cache.as_ref().is_some_and(|f| f.cmd == cmd && f.params == *p) {
|
||||
let feedback = self.measure_deferred_preview(cmd, p)?;
|
||||
@@ -1475,8 +1511,12 @@ impl Session {
|
||||
};
|
||||
let (overlays, transform_box) = if self.preview(cmd, p).is_ok() {
|
||||
let overlays = self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default();
|
||||
let objects = self.active().map(|st| st.selection.objects.clone()).unwrap_or_default();
|
||||
(overlays, if self.tool.resizing() { self.transform_box(&objects) } else { None })
|
||||
let (objects, color) = self
|
||||
.active()
|
||||
.map(|st| (st.selection.objects.clone(), st.selection.objects.first().map(|id| st.doc.layer_color(*id))))
|
||||
.unwrap_or_default();
|
||||
let transform_box = if self.measures_deferred_box() { self.transform_box(&objects).zip(color) } else { None };
|
||||
(overlays, transform_box)
|
||||
} else {
|
||||
(vec![], None)
|
||||
};
|
||||
@@ -1500,7 +1540,7 @@ pub(crate) struct DeferredFeedback {
|
||||
cmd: String,
|
||||
params: Value,
|
||||
overlays: Vec<Overlay>,
|
||||
transform_box: Option<vectorcraft_doc::OrientedBox>,
|
||||
transform_box: Option<(vectorcraft_doc::OrientedBox, [u8; 3])>,
|
||||
}
|
||||
|
||||
/// The most points the outlines of [`outlines_for_selection`] are drawn for, the same number as the
|
||||
|
||||
@@ -57,6 +57,24 @@ fn legacy_profile_name_resolves_in_commands_and_files() {
|
||||
cms::set_active(&before).unwrap();
|
||||
}
|
||||
|
||||
/// Assigning a profile other than the working space replaces the active color settings and raises
|
||||
/// the other open documents' revisions.
|
||||
#[test]
|
||||
fn assigning_another_profile_replaces_the_active_color_settings() {
|
||||
let _g = settings();
|
||||
let mut s = session();
|
||||
exec(&mut s, "file.new", json!({"width": 100, "height": 100}));
|
||||
let before = cms::active_settings();
|
||||
assert_ne!(before.cmyk, cms::DEVICE_CMYK);
|
||||
let active = cms::active();
|
||||
let other = s.documents()[0].revision;
|
||||
exec(&mut s, "edit.assignProfile", json!({"cmyk": cms::DEVICE_CMYK}));
|
||||
assert_eq!(cms::active_settings().cmyk, cms::DEVICE_CMYK);
|
||||
assert!(!std::sync::Arc::ptr_eq(&active, &cms::active()), "the active color settings were not replaced");
|
||||
assert!(s.documents()[0].revision > other, "the other document's revision did not change");
|
||||
cms::set_active(&before).unwrap();
|
||||
}
|
||||
|
||||
/// Separations Preview follows the Color Settings and the profiles loaded: a renderer draws a gray
|
||||
/// square on the Black plate through the new working CMYK profile, and the four process plates
|
||||
/// through a proof profile loaded again under the same name, as a new renderer draws them.
|
||||
@@ -428,6 +446,60 @@ fn loading_the_working_rgb_profile_again_takes_effect_at_once() {
|
||||
assert!(redrawn, "the canvas redraws");
|
||||
}
|
||||
|
||||
/// Loading a new file of the CMYK profile View → Proof Setup targets, or removing that profile,
|
||||
/// gives the documents new revisions, as a working space's new file does; loading or removing
|
||||
/// another profile does not.
|
||||
#[test]
|
||||
fn loading_or_removing_the_proof_profile_redraws() {
|
||||
let _g = settings();
|
||||
// A built-in CMYK profile's file renamed `name`: padded with spaces, which names drop.
|
||||
let renamed = |builtin: &str, name: &str| {
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
let mut bytes = cms::icc_bytes(builtin).unwrap().to_vec();
|
||||
let (from, to) = (utf16(builtin), utf16(&format!("{name:<width$}", width = builtin.len())));
|
||||
let at = bytes.windows(from.len()).position(|w| w == from).unwrap();
|
||||
bytes[at..at + to.len()].copy_from_slice(&to);
|
||||
bytes
|
||||
};
|
||||
let dir = fresh_dir("reload-proof-cmyk");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let _reset = ColorSettingsReset { profile: None, paths: vec![dir.clone()] };
|
||||
let load = |s: &mut Session, name: &str| {
|
||||
let path = dir.join(format!("{}.icc", name.len()));
|
||||
std::fs::write(&path, renamed(cms::GENERIC_CMYK, name)).unwrap();
|
||||
let r = s.execute("color.loadProfile", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(r["name"], name);
|
||||
s.doc().unwrap().revision
|
||||
};
|
||||
let (proofed, other) = ("Reload Proof CMYK", "Reload Other CMYK 2");
|
||||
let _view = ProofReset([proofed, other]);
|
||||
let mut s = session();
|
||||
load(&mut s, proofed);
|
||||
load(&mut s, other);
|
||||
exec(&mut s, "view.proofSetup", json!({"target": format!("cmyk:{proofed}"), "proof": true}));
|
||||
let revision = s.doc().unwrap().revision;
|
||||
assert_eq!(load(&mut s, other), revision, "another profile's file");
|
||||
let reloaded = load(&mut s, proofed);
|
||||
assert!(reloaded > revision, "the proof profile's file");
|
||||
exec(&mut s, "color.removeProfile", json!({"name": other}));
|
||||
assert_eq!(s.doc().unwrap().revision, reloaded, "another profile removed");
|
||||
exec(&mut s, "color.removeProfile", json!({"name": proofed}));
|
||||
assert!(s.doc().unwrap().revision > reloaded, "the proof profile removed");
|
||||
}
|
||||
|
||||
/// Sets the default proof view and unregisters the profiles when dropped, also after a failed
|
||||
/// assertion.
|
||||
struct ProofReset([&'static str; 2]);
|
||||
|
||||
impl Drop for ProofReset {
|
||||
fn drop(&mut self) {
|
||||
vectorcraft_render::proof::set_view(Default::default());
|
||||
for name in self.0 {
|
||||
cms::unregister_icc(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder of its own for the test `tag`, empty.
|
||||
fn fresh_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = vectorcraft_testkit::temp_dir(&format!("color-settings-{tag}"));
|
||||
|
||||
@@ -2958,6 +2958,13 @@ fn the_hpgl_sample_opens() {
|
||||
assert!(paths > 15, "{paths} paths, warnings {:?}", l.warnings);
|
||||
}
|
||||
|
||||
/// A wedge drawn with the adaptive line type `LT-1` and a near-zero radius (a case the fuzz test
|
||||
/// found) opens and places without a panic.
|
||||
#[test]
|
||||
fn an_adaptive_line_type_wedge_of_almost_no_radius_opens() {
|
||||
survive_metafile("HP-GL tiny adaptive wedge", "x.plt", b"IN;SP1;LT-1;EW0.000001,0,0;").unwrap();
|
||||
}
|
||||
|
||||
/// A random instruction: a mnemonic the reader plays (or one it doesn't) with hostile numbers.
|
||||
fn arb_hpgl_instruction() -> impl Strategy<Value = String> {
|
||||
let op = prop::sample::select(vec![
|
||||
|
||||
@@ -294,6 +294,134 @@ fn writing_again_gives_the_same_bytes() {
|
||||
assert!(first.warnings.is_empty(), "{:?}", first.warnings);
|
||||
}
|
||||
|
||||
/// The CMYK equivalent written beside RGB or Lab values is the one of the values as written (to four
|
||||
/// decimals, Lab clamped to the range a reader reads), so the reopened file is written with the same
|
||||
/// numbers: a new document's swatches, and colors of many decimals in fills, gradient stops, global
|
||||
/// process swatches (RGB and Lab), an RGB spot swatch (in a fill and in a stop), Lab spot swatches
|
||||
/// and a Lab spot swatch out of range.
|
||||
#[test]
|
||||
fn cmyk_equivalents_are_written_again_unchanged() {
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
let v = |i: u16, s: f32| (f32::from(i) * s + 0.123_456_7).fract();
|
||||
let rgb = |i: u16| Color::rgb(v(i, 0.137_911_3), v(i, 0.271_828_2), v(i, 0.314_159_3));
|
||||
let lab = |i: u16| Color::lab(v(i, 0.137_911_3) * 100.0, v(i, 0.271_828_2) * 200.0 - 100.0, v(i, 0.314_159_3) * 200.0 - 100.0);
|
||||
let glaze = rgb(102);
|
||||
let swatch = |name: String, c: Color, spot: bool| Swatch { name, paint: solid(c), global: true, spot };
|
||||
let mut named: Vec<(String, Color, bool)> =
|
||||
vec![("Glaze".into(), glaze, true), ("Out".into(), Color::lab(104.567_89, -140.123_46, 131.987_65), true)];
|
||||
for i in 0..8u16 {
|
||||
named.push((format!("Leaf {i}"), rgb(i + 110), false));
|
||||
named.push((format!("Fern {i}"), lab(i + 120), false));
|
||||
named.push((format!("Moss {i}"), lab(i + 130), true));
|
||||
}
|
||||
let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect();
|
||||
swatches.extend(named.iter().map(|(name, c, spot)| swatch(name.clone(), *c, *spot)));
|
||||
doc.swatches = swatches.into();
|
||||
let layer = doc.layers[0].id;
|
||||
for i in 0..24u16 {
|
||||
let x = f64::from(i % 8) * 30.0 + 10.0;
|
||||
let y = f64::from(i / 8) * 30.0 + 10.0;
|
||||
let n = square(&mut doc, Rect::new(x, y, x + 20.0, y + 20.0), rgb(i));
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let mut stops: Vec<GradientStop> = (0..5u16).map(|i| GradientStop::new(f32::from(i) / 5.0, rgb(i + 50))).collect();
|
||||
let out = named[1].1;
|
||||
stops.push(GradientStop { swatch: Some("Out".into()), tint: 0.5, ..GradientStop::new(0.9, out.tinted(0.5)) });
|
||||
stops.push(GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) });
|
||||
let mut fills = vec![gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None)];
|
||||
fills.extend(named.iter().map(|(name, c, spot)| linked(name, *c, if *spot { 1.0 } else { 0.6 })));
|
||||
for (i, fill) in fills.into_iter().enumerate() {
|
||||
let x = (i % 9) as f64 * 30.0 + 10.0;
|
||||
let y = (i / 9) as f64 * 30.0 + 110.0;
|
||||
let n = painted(&mut doc, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let first = program(&doc);
|
||||
let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap().replace("\r\n", "\n");
|
||||
same_lines(&again.lines().map(String::from).collect::<Vec<_>>(), &first.lines().map(String::from).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// With a working RGB space other than sRGB, an RGB fill, an RGB gradient stop and an RGB spot
|
||||
/// color in a stop are written with their working RGB values, the values the reader reads back, so
|
||||
/// the reopened file is written with the same RGB values and CMYK equivalents.
|
||||
#[test]
|
||||
fn rgb_values_are_written_again_unchanged_in_a_wide_gamut_working_space() {
|
||||
const CHILD: &str = "VECTORCRAFT_TEST_AI_EDITING_WIDE_GAMUT";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
// Color settings are process-wide: exercise this profile in a separate test process.
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "ai_editing::tests::rgb_values_are_written_again_unchanged_in_a_wide_gamut_working_space", "--nocapture"])
|
||||
.env(CHILD, "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
|
||||
"isolated color profile test failed:\n{stdout}\n{}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
return;
|
||||
}
|
||||
use vectorcraft_color::cms;
|
||||
cms::set_active(&cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap();
|
||||
assert!(!cms::active().rgb_is_srgb());
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
let glaze = Color::rgb(0.812_345_6, 0.234_567_8, 0.456_789_1);
|
||||
let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect();
|
||||
swatches.push(Swatch { name: "Glaze".into(), paint: solid(glaze), global: true, spot: true });
|
||||
doc.swatches = swatches.into();
|
||||
let layer = doc.layers[0].id;
|
||||
let n = square(&mut doc, Rect::new(10.0, 10.0, 30.0, 30.0), Color::rgb(0.8, 0.4, 0.2));
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let stops = vec![
|
||||
GradientStop::new(0.0, Color::rgb(0.1, 0.7, 0.3)),
|
||||
GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) },
|
||||
];
|
||||
let fill = gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None);
|
||||
let n = painted(&mut doc, shapes::rectangle(Rect::new(40.0, 10.0, 60.0, 30.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let first = program(&doc);
|
||||
assert!(first.contains("0.8 0.4 0.2 Xa"), "the fill's working RGB values are written");
|
||||
let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap().replace("\r\n", "\n");
|
||||
same_lines(&again.lines().map(String::from).collect::<Vec<_>>(), &first.lines().map(String::from).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// A gradient stop of a Lab spot color writes the RGB values of the Lab values as written (to four
|
||||
/// decimals, clamped to the range a reader reads), the same Lab values its CMYK equivalent and the
|
||||
/// palette's definition come from: for Lab values of many decimals and for Lab values out of range.
|
||||
#[test]
|
||||
fn a_lab_spot_stop_writes_rgb_values_of_its_lab_values_as_written() {
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
let v = |i: u16, s: f32| (f32::from(i) * s + 0.123_456_7).fract();
|
||||
let mut spots: Vec<(String, Color)> = vec![("Out".into(), Color::lab(104.567_89, -140.123_46, 131.987_65))];
|
||||
spots.push(("Rim".into(), Color::lab(55.555_55, 131.234_56, -12.345_67)));
|
||||
for i in 0..8u16 {
|
||||
spots
|
||||
.push((format!("Moss {i}"), Color::lab(v(i, 0.137_911_3) * 100.0, v(i, 0.271_828_2) * 200.0 - 100.0, v(i, 0.314_159_3) * 200.0 - 100.0)));
|
||||
}
|
||||
let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect();
|
||||
swatches.extend(spots.iter().map(|(name, c)| Swatch { name: name.clone(), paint: solid(*c), global: true, spot: true }));
|
||||
doc.swatches = swatches.into();
|
||||
let layer = doc.layers[0].id;
|
||||
for (i, (name, c)) in spots.iter().enumerate() {
|
||||
let stops = vec![
|
||||
GradientStop::new(0.0, Color::rgb(0.1, 0.2, 0.3)),
|
||||
GradientStop { swatch: Some(name.clone()), tint: 0.5, ..GradientStop::new(1.0, c.tinted(0.5)) },
|
||||
];
|
||||
let fill = gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None);
|
||||
let x = i as f64 * 25.0 + 10.0;
|
||||
let n = painted(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let program = program(&doc);
|
||||
for (name, c) in &spots {
|
||||
let mut want = String::new();
|
||||
crate::ps::push_nums(&mut want, &crate::ai_ops::as_written(*c).to_rgb().map(|v| f64::from(crate::ai_ops::unit(v))));
|
||||
want.push_str(&format!("({name}) 0.5 1 4 "));
|
||||
assert!(program.lines().any(|l| l.contains(&want)), "no stop of {name} writes {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_data_is_zstandard_compressed_with_the_header_as_metadata() {
|
||||
let doc = sample();
|
||||
@@ -3815,6 +3943,42 @@ fn korean_line_break_anywhere_is_reported_only_for_korean_paragraphs() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text document has no Bunri-Kinshi: a paragraph with it on and an inseparable pair in it is
|
||||
/// reported as left out, and comes back off; with it off, or with no pair, nothing is reported.
|
||||
#[test]
|
||||
fn bunri_kinshi_is_reported_only_for_paragraphs_with_a_pair() {
|
||||
use vectorcraft_doc::TextObject;
|
||||
for (text, on, reported) in [
|
||||
("Wait——now", true, true),
|
||||
("Wait——now", false, false),
|
||||
("Wait—now", true, false),
|
||||
("Wait……now", true, true),
|
||||
("Wait‥‥now", true, true),
|
||||
("あ〳〵い", true, true),
|
||||
("あ〴〵い", true, true),
|
||||
("あ〵〳い", true, false),
|
||||
] {
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), text, style(14.0, Color::BLACK));
|
||||
t.para.bunri_kinshi = on;
|
||||
let mut doc = Document::new(200.0, 200.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let n = text_node(&mut doc, t);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(!out.warnings.is_empty(), reported, "{text} {on}: {:?}", out.warnings);
|
||||
let mut all = vec![];
|
||||
paths(&read_back(&doc).layers, &mut all);
|
||||
let paras: Vec<bool> = all
|
||||
.iter()
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.para_at(0).bunri_kinshi),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(paras, [false], "{text} {on}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_is_written_again_as_the_same_bytes() {
|
||||
let doc = typed();
|
||||
@@ -5872,25 +6036,6 @@ fn outlines(doc: &Document) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Fails with the lines where `got` and `want` differ in more than their numbers' last written
|
||||
/// decimal (a color read back from four decimals gives its other model's values again from them).
|
||||
fn near_lines(got: &str, want: &str) {
|
||||
let near = |a: &str, b: &str| {
|
||||
let (a, b): (Vec<&str>, Vec<&str>) = (a.split(' ').collect(), b.split(' ').collect());
|
||||
a.len() == b.len()
|
||||
&& a.iter().zip(&b).all(|(x, y)| match (x.trim_start_matches("%_").parse::<f64>(), y.trim_start_matches("%_").parse::<f64>()) {
|
||||
(Ok(x), Ok(y)) => (x - y).abs() <= 2e-3,
|
||||
_ => x == y,
|
||||
})
|
||||
};
|
||||
let (g, w): (Vec<&str>, Vec<&str>) = (got.lines().collect(), want.lines().collect());
|
||||
let differ: Vec<String> = (0..g.len().max(w.len()))
|
||||
.filter(|&i| !matches!((g.get(i), w.get(i)), (Some(a), Some(b)) if near(a, b)))
|
||||
.map(|i| format!("line {i}:\n got {:?}\n want {:?}", g.get(i), w.get(i)))
|
||||
.collect();
|
||||
assert!(differ.is_empty(), "{}", differ.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_with_live_effects_and_graphic_styles_are_read_back() {
|
||||
let doc = styles_document();
|
||||
@@ -6212,25 +6357,7 @@ fn art_styles_write_the_same_bytes_again() {
|
||||
assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0);
|
||||
// What is read back is written as it was.
|
||||
let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program;
|
||||
near_lines(&rgb_without_cmyk(&shown(&again)), &rgb_without_cmyk(&shown(&first.program)));
|
||||
}
|
||||
|
||||
/// `program` with the CMYK equivalents of plain RGB colors blanked out: the first four numbers of
|
||||
/// `c m y k r g b Xa` (or `XA`) and of an RGB gradient stop's `c m y k r g b 2 ... Bs`. Named and
|
||||
/// spot RGB colors (`... (name) tint Xk`, `Xx` and spot stops) are not blanked; the document this
|
||||
/// is used on has none. The Generic CMYK separation of a color at the edge of the press gamut can
|
||||
/// land on one of two results that differ by up to 0.01 when the RGB changes in its last float
|
||||
/// bits, as it does between the first save and the save of the reopened file, and the platform's
|
||||
/// float math decides which. The RGB values stay compared.
|
||||
fn rgb_without_cmyk(program: &str) -> String {
|
||||
let blank = |line: &str| {
|
||||
let (prefix, rest) = line.strip_prefix("%_").map_or(("", line), |r| ("%_", r));
|
||||
let t: Vec<&str> = rest.split(' ').collect();
|
||||
let color = t.len() == 8 && matches!(t[7], "Xa" | "XA");
|
||||
let stop = t.len() > 8 && t[7] == "2" && t.last() == Some(&"Bs");
|
||||
if color || stop { format!("{prefix}_ _ _ _ {}", t[4..].join(" ")) } else { line.to_string() }
|
||||
};
|
||||
program.lines().map(blank).collect::<Vec<_>>().join("\n")
|
||||
same_lines(&shown(&again).lines().map(String::from).collect::<Vec<_>>(), &shown(&first.program).lines().map(String::from).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// `n` on the first layer of `doc`.
|
||||
|
||||
@@ -1357,8 +1357,9 @@ impl<'a> Writer<'a> {
|
||||
self.plain_color(*color, stroke);
|
||||
}
|
||||
|
||||
/// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB, and
|
||||
/// Lab as RGB), with its CMYK equivalent.
|
||||
/// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB in the
|
||||
/// working RGB space, and Lab as display RGB), with its CMYK equivalent (an RGB color's from its
|
||||
/// values as written, a Lab color's from its Lab values).
|
||||
fn plain_color(&mut self, color: Color, stroke: bool) {
|
||||
let s = &mut self.body;
|
||||
let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill };
|
||||
@@ -1373,19 +1374,22 @@ impl<'a> Writer<'a> {
|
||||
let _ = writeln!(s, "{}", op("k", "K"));
|
||||
}
|
||||
Color::Rgb { .. } | Color::Lab { .. } => {
|
||||
let cmyk = self.cms.to_cmyk(&color, self.cms.settings().intent).map(unit);
|
||||
let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color };
|
||||
let cmyk = self.cms.to_cmyk(&source, self.cms.settings().intent).map(unit);
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v))));
|
||||
ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from));
|
||||
let _ = writeln!(s, "{}", op("Xa", "XA"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A spot (`spot`) or global process color `name` of full color `full` at `tint`, written to
|
||||
/// `s`: its CMYK equivalent, its RGB or Lab values, the name, how much lighter than the full
|
||||
/// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before
|
||||
/// `Xx` (spot) or `Xk` (global process).
|
||||
/// `s`: its CMYK equivalent (from its RGB or Lab values as written), its RGB or Lab values
|
||||
/// (clamped as [`ai_ops::as_written`] clamps them), the name, how much lighter than the full
|
||||
/// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before `Xx`
|
||||
/// (spot) or `Xk` (global process).
|
||||
fn named(&self, s: &mut String, name: &str, full: Color, tint: f32, spot: bool, stroke: bool) {
|
||||
let full = ai_ops::as_written(full);
|
||||
let cmyk = self.cms.to_cmyk(&self.doc.linked_color(full, spot), self.cms.settings().intent).map(unit);
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let model = match full {
|
||||
@@ -1481,7 +1485,8 @@ impl<'a> Writer<'a> {
|
||||
true
|
||||
}
|
||||
|
||||
/// The spot color swatches, with their full colors' CMYK equivalents, for the header.
|
||||
/// The spot color swatches, with their full colors' CMYK equivalents (as the palette writes
|
||||
/// them), for the header.
|
||||
pub(super) fn spot_swatches(&self) -> Vec<(String, [f32; 4])> {
|
||||
let intent = self.cms.settings().intent;
|
||||
self.doc
|
||||
@@ -1489,7 +1494,7 @@ impl<'a> Writer<'a> {
|
||||
.filter(|sw| sw.spot)
|
||||
.filter_map(|sw| match sw.paint {
|
||||
Paint::Solid { color, swatch: None, .. } => {
|
||||
Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(color, true), intent).map(unit)))
|
||||
Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(ai_ops::as_written(color), true), intent).map(unit)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, BezPath, Rect, Shape, Vec2};
|
||||
|
||||
use super::{MAX_NEST, Writer, Xy};
|
||||
use crate::ai_ops::{average, unit};
|
||||
use crate::ai_ops::{self, average, unit};
|
||||
use crate::ps;
|
||||
|
||||
pub(in crate::ai_editing) const GRADIENTS: &str =
|
||||
@@ -371,7 +371,11 @@ impl Writer<'_> {
|
||||
/// A stop's color and its style: gray `lightness 0`, CMYK `c m y k 1`, RGB (and Lab, as RGB)
|
||||
/// `c m y k r g b 2` with its CMYK equivalent; a spot color its full color's CMYK equivalent,
|
||||
/// then its RGB values when it isn't CMYK, its name and how much lighter than the full color,
|
||||
/// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB).
|
||||
/// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). RGB values are an RGB color's own (in the
|
||||
/// working RGB space) and a Lab color's display RGB ([`ai_ops::rgb_values`]). An RGB color's CMYK
|
||||
/// equivalent is converted from its values as written ([`ai_ops::as_written`]), a Lab color's
|
||||
/// from its Lab values, and a spot color's CMYK equivalent and RGB values both come from its full
|
||||
/// color as the palette writes it.
|
||||
fn stop_color(&mut self, s: &mut String, stop: &GradientStop) {
|
||||
let doc = self.doc;
|
||||
let intent = self.cms.settings().intent;
|
||||
@@ -380,11 +384,12 @@ impl Writer<'_> {
|
||||
&& let Paint::Solid { color: full, swatch: None, .. } = sw.paint
|
||||
{
|
||||
if sw.spot {
|
||||
let full = ai_ops::as_written(full);
|
||||
let cmyk = self.cms.to_cmyk(&doc.linked_color(full, true), intent).map(unit);
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let rgb = !matches!(full, Color::Cmyk { .. });
|
||||
if rgb {
|
||||
ps::push_nums(s, &full.to_rgb().map(|v| f64::from(unit(v))));
|
||||
ps::push_nums(s, &ai_ops::rgb_values(full).map(f64::from));
|
||||
}
|
||||
s.push_str(&ps::bytes(name.as_bytes()));
|
||||
s.push(' ');
|
||||
@@ -406,9 +411,10 @@ impl Writer<'_> {
|
||||
s.push_str("1 ");
|
||||
}
|
||||
color @ (Color::Rgb { .. } | Color::Lab { .. }) => {
|
||||
let cmyk = self.cms.to_cmyk(&color, intent).map(unit);
|
||||
let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color };
|
||||
let cmyk = self.cms.to_cmyk(&source, intent).map(unit);
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v))));
|
||||
ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from));
|
||||
s.push_str("2 ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +387,7 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
|
||||
}
|
||||
let hangul =
|
||||
text.chars().any(|c| matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}' | '\u{FFA0}'..='\u{FFDC}'));
|
||||
let pairs = text.chars().zip(text.chars().skip(1)).any(|(a, b)| vectorcraft_text::inseparable(a, b));
|
||||
let held = ParaStyle {
|
||||
justify: p.justify,
|
||||
left_indent: p.left_indent,
|
||||
@@ -403,6 +404,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
|
||||
// The text document has no Korean Line Break Anywhere: it reads back off, which
|
||||
// changes only the lines of a paragraph with Hangul in it.
|
||||
korean_line_break_anywhere: p.korean_line_break_anywhere && !hangul,
|
||||
// Nor Bunri-Kinshi: it reads back off, which changes only the lines of a paragraph
|
||||
// with an inseparable pair in it.
|
||||
bunri_kinshi: p.bunri_kinshi && !pairs,
|
||||
..ParaStyle::default()
|
||||
};
|
||||
*p != held
|
||||
|
||||
@@ -103,6 +103,27 @@ pub(crate) fn unit(v: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// `color` as a reader reads it back from the values written for it ([`ps::written`]): RGB values
|
||||
/// clamped to 0..1, L to 0..100 and a and b to -128..127, each to four decimals; other colors
|
||||
/// unchanged.
|
||||
pub(crate) fn as_written(color: Color) -> Color {
|
||||
let w = |v: f32| ps::written(f64::from(v)) as f32;
|
||||
match color {
|
||||
Color::Rgb { r, g, b } => Color::rgb(w(unit(r)), w(unit(g)), w(unit(b))),
|
||||
Color::Lab { l, a, b } => Color::lab(w(l.clamp(0.0, 100.0)), w(a.clamp(-128.0, 127.0)), w(b.clamp(-128.0, 127.0))),
|
||||
c => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// The RGB values written for `color`, clamped to 0..1: an RGB color's own values (in the working
|
||||
/// RGB space, as a reader reads them back), any other color's display RGB.
|
||||
pub(crate) fn rgb_values(color: Color) -> [f32; 3] {
|
||||
match color {
|
||||
Color::Rgb { r, g, b } => [r, g, b].map(unit),
|
||||
c => c.to_rgb().map(unit),
|
||||
}
|
||||
}
|
||||
|
||||
/// The average colour of a gradient's stops (in RGB).
|
||||
pub(crate) fn average(g: &Gradient) -> Color {
|
||||
let n = g.stops.len().max(1) as f32;
|
||||
|
||||
@@ -792,6 +792,8 @@ const ANYTHING: Under = Under { strokes: true, patterns: true };
|
||||
/// The Transform effect `f` (the factor and radian entries; the percent and degree ones are what
|
||||
/// its dialog shows). `None` keeps the drawn look: an entry not read, Random (the file holds no
|
||||
/// seed), or a missing Scale Strokes & Effects or Transform Patterns that would change `under`.
|
||||
/// The keys are LiveEffect XML's
|
||||
/// (https://community.adobe.com/questions-652/how-to-define-values-in-action-menu-command-816878).
|
||||
fn transform(f: &Filter, under: Under) -> Option<serde_json::Value> {
|
||||
for key in ["scaleH_Percent", "scaleV_Percent", "rotate_Degrees"] {
|
||||
entry(f, key, Kind::Real).ok()?;
|
||||
@@ -841,6 +843,9 @@ fn transform(f: &Filter, under: Under) -> Option<serde_json::Value> {
|
||||
/// corners are measured in: the distorted box is laid over the object's own bounds, as the catalog
|
||||
/// effect lays it, also when the frame is another size (a unit square, or bounds with the stroke).
|
||||
/// No corners is no change; some but not all, or a frame that isn't an upright rectangle, isn't read.
|
||||
/// The keys are LiveEffect XML's (https://community.adobe.com/questions-652/pageitem-applyeffect-liveeffectxml-769450),
|
||||
/// with the corners in that order
|
||||
/// (https://raw.githubusercontent.com/mark1bean/live-effect-functions-for-illustrator/master/scripts/LE_Functions.js).
|
||||
fn free_distort(f: &Filter) -> Option<serde_json::Value> {
|
||||
let mut corners = [[[0.0; 2]; 4]; 2];
|
||||
let mut held = 0;
|
||||
@@ -895,8 +900,10 @@ fn points(f: &Filter) -> Option<&'static str> {
|
||||
Some(if switch(f, "roundness", Kind::Real).ok()?.unwrap_or(false) { "smooth" } else { "corner" })
|
||||
}
|
||||
|
||||
/// The Tweak effect `f` (`Adobe Scribble and Tweak`): relative unless `absoluteness` says otherwise,
|
||||
/// moving anchors and both handles unless the dictionary turns them off.
|
||||
/// The Tweak effect `f` (`TWEAK`): relative unless `absoluteness` says otherwise,
|
||||
/// moving anchors and both handles unless the dictionary turns them off. The keys are LiveEffect
|
||||
/// XML's: `horz` and `vert` relative, `ahor` and `aver` absolute, and `anch`, `in` and `out`
|
||||
/// (https://raw.githubusercontent.com/mark1bean/live-effect-functions-for-illustrator/master/scripts/LE_Functions.js).
|
||||
fn tweak(f: &Filter) -> Option<serde_json::Value> {
|
||||
let (absolute, h) = sized(f, false, "horz", "ahor")?;
|
||||
let (_, v) = sized(f, false, "vert", "aver")?;
|
||||
@@ -906,7 +913,9 @@ fn tweak(f: &Filter) -> Option<serde_json::Value> {
|
||||
}
|
||||
|
||||
/// The Zig Zag effect `f`: absolute unless `absoluteness` says otherwise, 4 ridges and Corner points
|
||||
/// when they are missing.
|
||||
/// when they are missing. The keys are LiveEffect XML's: `amount` absolute, `relAmount` relative,
|
||||
/// `ridges` and `roundness` (https://community.adobe.com/questions-652/pageitem-applyeffect-liveeffectxml-769450,
|
||||
/// https://raw.githubusercontent.com/mark1bean/live-effect-functions-for-illustrator/master/scripts/LE_Functions.js).
|
||||
fn zig_zag(f: &Filter) -> Option<serde_json::Value> {
|
||||
let (absolute, size) = sized(f, true, "relAmount", "amount")?;
|
||||
let ridges = real(f, "ridges", 4.0, 0.0..=100.0).filter(|r| r.fract() == 0.0)?;
|
||||
@@ -1167,6 +1176,8 @@ fn effect(f: &Filter, under: Under) -> Option<Effect> {
|
||||
TRANSFORM => ("distort.transform", transform(f, under)?),
|
||||
FREE_DISTORT => ("distort.freeDistort", free_distort(f)?),
|
||||
// Negative puckers (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html).
|
||||
// Pucker & Bloat's `d_factor` and Twist's `angle` are LiveEffect XML's keys
|
||||
// (https://community.adobe.com/questions-652/pageitem-applyeffect-liveeffectxml-769450).
|
||||
PUCKER_BLOAT => ("distort.puckerBloat", json!({"amount": real(f, "d_factor", 0.0, -200.0..=200.0)?})),
|
||||
TWEAK => ("distort.tweak", tweak(f).filter(still)?),
|
||||
// A positive angle turns clockwise in both.
|
||||
|
||||
@@ -1027,8 +1027,7 @@ fn affine_corners_are_a_parallelogram_however_far_they_reach() {
|
||||
|
||||
#[test]
|
||||
fn roughen_keeps_the_drawn_look() {
|
||||
// Its outline is random and the file holds no seed; at a size of 0 the effect still redraws the
|
||||
// path's segments.
|
||||
// Its outline is random and the file holds no seed.
|
||||
assert_eq!(read_effect("Adobe Roughen", "30 /Real (size) ,\n"), None);
|
||||
assert_eq!(read_effect("Adobe Roughen", ""), None);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ mod state;
|
||||
mod strips;
|
||||
|
||||
pub(crate) use paint::collapse;
|
||||
#[cfg(test)]
|
||||
pub(crate) use paint::tilings_compared;
|
||||
pub(crate) use state::{GState, Out, Space, process};
|
||||
pub(crate) use strips::merge_strips;
|
||||
|
||||
|
||||
@@ -5,6 +5,19 @@ use super::{
|
||||
PsError, REGISTRATION, Rc, Rect, Res, SHADING_SAMPLES, Shared, UNKNOWN_FUNCTIONS, UNKNOWN_SHADINGS, Vec2, clamp_between, ps_err,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many patterns the lookups of tiling patterns on this thread compared with the pattern
|
||||
/// they looked for.
|
||||
static TILINGS_COMPARED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// [`TILINGS_COMPARED`] of this thread.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn tilings_compared() -> u64 {
|
||||
TILINGS_COMPARED.get()
|
||||
}
|
||||
|
||||
impl Interp<'_> {
|
||||
// ---------- colour ----------
|
||||
|
||||
@@ -212,7 +225,13 @@ impl Interp<'_> {
|
||||
let proc = get("PaintProc").ok_or(PsError::Ps("undefined", "PaintProc".into()))?;
|
||||
let key = proc.items().cloned().ok_or(PsError::Ps("typecheck", "PaintProc".into()))?;
|
||||
let hash = tiling_hash(&key, tile, under.as_ref());
|
||||
let made = self.out.tilings.get(&hash).and_then(|list| list.iter().find(|(p, t, u, _)| p.same(&key) && *t == tile && *u == under));
|
||||
let made = self.out.tilings.get(&hash).and_then(|list| {
|
||||
list.iter().find(|(p, t, u, _)| {
|
||||
#[cfg(test)]
|
||||
TILINGS_COMPARED.set(TILINGS_COMPARED.get().saturating_add(1));
|
||||
p.same(&key) && *t == tile && *u == under
|
||||
})
|
||||
});
|
||||
if let Some((.., name)) = made {
|
||||
return Ok(Paint::Pattern { pattern: name.clone(), xf });
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ pub(crate) struct Interp<'a> {
|
||||
/// The font a `kshow` procedure sees as current while type in a CIDFont only named is set in
|
||||
/// a font of its own (see `text`).
|
||||
pub kshow_font: Option<DictRef>,
|
||||
/// The `cshow` procedure running over type in a CIDFont only named, with the font it sees as
|
||||
/// current (see `text`).
|
||||
pub cshow: Option<super::text::CShowFont>,
|
||||
pub g: GState,
|
||||
pub saved: Vec<GState>,
|
||||
pub out: Out,
|
||||
@@ -240,6 +243,7 @@ impl<'a> Interp<'a> {
|
||||
cmap_font: 0,
|
||||
predefined_cmaps: vec![],
|
||||
kshow_font: None,
|
||||
cshow: None,
|
||||
type42_counted: HashSet::new(),
|
||||
g,
|
||||
saved: vec![],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user