mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 10:01:58 +00:00
Merge main into feat/cjk-insert-aki
This commit is contained in:
+301
-12
@@ -236,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;
|
||||
@@ -245,22 +246,45 @@ 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 })
|
||||
}
|
||||
|
||||
/// 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);
|
||||
at.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
|
||||
// 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];
|
||||
}
|
||||
@@ -487,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);
|
||||
@@ -524,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 {
|
||||
@@ -535,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()),
|
||||
@@ -542,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());
|
||||
@@ -560,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`).
|
||||
@@ -680,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.
|
||||
@@ -1597,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]
|
||||
@@ -1909,6 +2115,89 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -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"]),
|
||||
@@ -265,7 +267,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"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(
|
||||
|
||||
@@ -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
@@ -604,7 +604,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)
|
||||
}
|
||||
|
||||
@@ -944,7 +944,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
|
||||
@@ -960,7 +960,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;
|
||||
@@ -1245,8 +1245,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,
|
||||
@@ -1254,6 +1255,7 @@ fn transform_leaf(
|
||||
copies: usize,
|
||||
options: crate::distort::TransformOptions,
|
||||
budget: Budget,
|
||||
kept: Kept,
|
||||
) -> (Budget, bool) {
|
||||
let scaling = copy_scaling(options);
|
||||
if !options.objects {
|
||||
@@ -1266,6 +1268,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 {
|
||||
@@ -1275,9 +1278,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
|
||||
@@ -1423,9 +1427,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;
|
||||
@@ -1438,6 +1443,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.
|
||||
@@ -1584,9 +1590,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`
|
||||
@@ -1659,9 +1668,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) {
|
||||
@@ -1683,7 +1699,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;
|
||||
}
|
||||
@@ -1691,16 +1707,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() {
|
||||
@@ -1717,27 +1739,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);
|
||||
@@ -1754,8 +1794,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();
|
||||
@@ -1772,7 +1816,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 {
|
||||
@@ -1781,6 +1829,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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
mod rasterfollow;
|
||||
mod scale;
|
||||
mod transformfx;
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -20,6 +20,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.
|
||||
@@ -39,7 +40,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
|
||||
)]
|
||||
@@ -182,8 +183,14 @@ fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut De
|
||||
// Every image is checked before any is decoded.
|
||||
let mut images = vec![];
|
||||
for id in ids {
|
||||
let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue };
|
||||
let Some(n) = st.doc.node(id) else { continue };
|
||||
let NodeKind::Image(im) = &n.kind else { continue };
|
||||
let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?;
|
||||
// The result has an alpha channel, which a grayscale image colorized with a process color
|
||||
// can't take.
|
||||
if gray_colorized_with_process(&st.doc, n).is_some() {
|
||||
return Err(bad(C, PROCESS_ON_TRANSPARENCY));
|
||||
}
|
||||
// The preview stands in for a file that hasn't been read, or that is kept at its preview.
|
||||
if im.link.is_some() && blob.is_proxy() {
|
||||
return Err(bad(C, "the linked image shows its preview: embed or relink it first"));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
|
||||
@@ -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"))
|
||||
@@ -763,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))
|
||||
}
|
||||
|
||||
@@ -805,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";
|
||||
@@ -824,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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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`]).
|
||||
|
||||
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) →
|
||||
|
||||
@@ -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};
|
||||
@@ -1195,6 +1197,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> {
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Document, GraySource, Node, NodeId, NodeKind};
|
||||
|
||||
use super::appearance::{ItemTarget, appearance_targets, edit_items, edit_items_then, item_target};
|
||||
use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced};
|
||||
@@ -718,7 +718,8 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result:
|
||||
}
|
||||
|
||||
/// The message for a process color on a grayscale image with an alpha channel.
|
||||
const PROCESS_ON_TRANSPARENCY: &str = "a process color can't colorize a grayscale image with transparency: use a spot color, black or white";
|
||||
pub(super) const PROCESS_ON_TRANSPARENCY: &str =
|
||||
"a process color can't colorize a grayscale image with transparency: use a spot color, black or white";
|
||||
|
||||
/// Can `paint` colorize a grayscale image with an alpha channel: a color linked to a spot swatch
|
||||
/// of `d`, black or white?
|
||||
@@ -737,11 +738,7 @@ pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> {
|
||||
let mut refused = false;
|
||||
let mut visit = |n: &Node| {
|
||||
vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| {
|
||||
if let (NodeKind::Image(im), Some(paint)) = (&m.kind, m.extras().colorize.as_deref())
|
||||
&& !refused
|
||||
&& !colorizes_transparency(d, paint)
|
||||
&& d.image_blob(im).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha)
|
||||
{
|
||||
if !refused && gray_colorized_with_process(d, m).is_some_and(|g| g.alpha) {
|
||||
refused = true;
|
||||
}
|
||||
})
|
||||
@@ -761,6 +758,13 @@ pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::R
|
||||
if was_ok { check_colorized(d) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// How the pixels of `n` are gray, when `n` is a grayscale or 1-bit image colorized with a color
|
||||
/// that [`colorizes_transparency`] refuses. Such an image can't have an alpha channel.
|
||||
pub(super) fn gray_colorized_with_process(d: &Document, n: &Node) -> Option<GraySource> {
|
||||
let (NodeKind::Image(im), Some(paint)) = (&n.kind, n.extras().colorize.as_deref()) else { return None };
|
||||
if colorizes_transparency(d, paint) { None } else { d.image_blob(im).and_then(|b| b.gray_source()) }
|
||||
}
|
||||
|
||||
/// Is `e` the refusal of [`check_colorized`]?
|
||||
pub(super) fn is_colorize_refusal(e: &EngineError) -> bool {
|
||||
e.to_string().contains(PROCESS_ON_TRANSPARENCY)
|
||||
|
||||
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 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)),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -455,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();
|
||||
@@ -475,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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -568,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,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}"));
|
||||
|
||||
@@ -3943,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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ use vectorcraft_color::{Color, GradientKind, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, ColorMode, Document, LineCap, LineJoin, Node, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::import::graphics::{GState, Out, tilings_compared};
|
||||
use crate::import::interp::Interp;
|
||||
use crate::import::obj::Obj;
|
||||
use crate::import::{Dsc, import};
|
||||
use crate::{EpsOptions, Level, Preview, Raster};
|
||||
|
||||
@@ -369,12 +372,19 @@ fn dash_patterns_hold_at_most_65535_entries() {
|
||||
|
||||
/// Saved and copied graphics states share the dash pattern, and `setdash`, `currentdash` and
|
||||
/// each stroke count the entries they read or copy against the budgets. Under a pattern of
|
||||
/// 65,535 entries, 4,000,000 passes of `gsave grestore` and 2,000,000 passes of
|
||||
/// `gstate currentgstate setgstate` finish, and loops of `setdash`, `currentdash` and `strokepath`
|
||||
/// run out of a budget.
|
||||
/// 65,535 entries, the states that `gsave`, `grestore`, `gstate`, `currentgstate` and `setgstate`
|
||||
/// save, make or set hold that one pattern, 4,000,000 passes of `gsave grestore` and 2,000,000
|
||||
/// passes of `gstate currentgstate setgstate` finish, and loops of `setdash`, `currentdash` and
|
||||
/// `strokepath` run out of a budget.
|
||||
#[test]
|
||||
fn work_on_the_longest_dash_pattern_ends_soon() {
|
||||
let dash = "0 0 10 10 rectfill /a 65535 array def 0 1 65534 { a exch 1 put } for a 0 setdash";
|
||||
let shared = run_looking(&format!("{dash} gsave gsave grestore gstate gstate currentgstate dup setgstate"), |it| {
|
||||
let Some(saved) = it.saved.last().map(|g| g.dash.clone()) else { return false };
|
||||
let made: Vec<_> = it.stack.iter().filter_map(|o| if let Obj::GState(g) = o { Some(g.dash.clone()) } else { None }).collect();
|
||||
saved.len() == 65_535 && made.len() == 2 && made.iter().chain([&it.g.dash]).all(|m| std::rc::Rc::ptr_eq(m, &saved))
|
||||
});
|
||||
assert!(shared);
|
||||
for body in ["4000000 { gsave grestore } repeat", "2000000 { gstate currentgstate setgstate } repeat"] {
|
||||
let r = read_soon(&format!("{dash} {body} 20 20 10 10 rectfill"));
|
||||
assert_eq!(objects(&r.document).len(), 2, "{body}: {:?}", r.warnings);
|
||||
@@ -530,13 +540,15 @@ fn non_finite_arithmetic_is_undefinedresult() {
|
||||
assert!(!objects(&r.document).is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Tiling patterns and spot inks are found again through a hash of what tells them apart, so a
|
||||
/// program that makes 200,000 patterns and 200,001 inks reads within [`read_soon`]'s 20 seconds.
|
||||
/// Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A pattern made
|
||||
/// again from its prototype is the same pattern, and so is an uncolored pattern set again in a
|
||||
/// color it was set in before.
|
||||
/// Tiling patterns are found again through a hash of what tells them apart. A program that sets
|
||||
/// 200,000 different patterns and also sets 200,001 spot inks reads them all, and its 200,000
|
||||
/// pattern lookups compare at most 200,000 earlier patterns in all; going through every pattern
|
||||
/// made so far would compare about 2 × 10^10. The test does not count or time the spot ink
|
||||
/// lookups. Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A
|
||||
/// pattern made again from its prototype is the same pattern, and so is an uncolored pattern set
|
||||
/// again in a color it was set in before.
|
||||
#[test]
|
||||
fn many_tiling_patterns_and_spot_inks_read_in_linear_time() {
|
||||
fn many_tiling_patterns_are_found_again_through_a_hash() {
|
||||
let r = read(
|
||||
"/pp { pop 0 0 1 1 rectfill } def \
|
||||
/a << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep 2 /YStep 2 /PaintProc /pp load >> def \
|
||||
@@ -560,7 +572,7 @@ fn many_tiling_patterns_and_spot_inks_read_in_linear_time() {
|
||||
assert!(names.iter().all(|n| d.swatch(n).is_some_and(|s| matches!(s.paint, Paint::Pattern { .. }))));
|
||||
// 100,000 patterns whose cells differ, an uncolored pattern in 100,000 tints of an ink that is
|
||||
// white at every tint (only the tint tells those patterns apart), and 200,000 more inks.
|
||||
let r = read_soon(
|
||||
let (r, compared) = read_counting_tilings(
|
||||
"0 1 99999 { /i exch def << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep i 2 add /YStep 1 \
|
||||
/PaintProc { pop 0 0 1 1 rectfill } >> matrix makepattern setpattern } for \
|
||||
[/Separation (Spot) /DeviceGray { pop 1 }] setcolorspace \
|
||||
@@ -568,6 +580,7 @@ fn many_tiling_patterns_and_spot_inks_read_in_linear_time() {
|
||||
matrix makepattern def 0 1 99999 { 100000 div p setpattern } for \
|
||||
0 1 199999 { /i exch def [/Separation i 8 string cvs /DeviceGray {}] setcolorspace } for 0 0 1 1 rectfill",
|
||||
);
|
||||
assert!(compared <= 200_000, "{compared} patterns compared");
|
||||
let d = &r.document;
|
||||
assert_eq!(d.patterns.len(), 200_000);
|
||||
assert!(d.patterns.iter().enumerate().all(|(i, p)| p.name == format!("Pattern {}", i + 1)));
|
||||
@@ -1112,19 +1125,51 @@ fn hostile_executable_files_and_type_loops_are_refused() {
|
||||
blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor");
|
||||
}
|
||||
|
||||
/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes
|
||||
/// more than 20 s.
|
||||
/// How long [`read_soon`] and [`read_counting_tilings`] wait, in seconds: a guard against a read
|
||||
/// that never ends. The budgets, limits and counts each test names decide what it checks.
|
||||
const NEVER_ENDS: u64 = 300;
|
||||
|
||||
/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading has not
|
||||
/// finished after [`NEVER_ENDS`] seconds.
|
||||
fn read_soon(body: &str) -> crate::Imported {
|
||||
read_within(body, 20)
|
||||
read_within(body, NEVER_ENDS)
|
||||
}
|
||||
|
||||
/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes
|
||||
/// more than `secs` seconds.
|
||||
fn read_within(body: &str, secs: u64) -> crate::Imported {
|
||||
read_counting(body, secs).0
|
||||
}
|
||||
|
||||
/// [`read_soon`], with how many patterns the tiling pattern lookups compared while reading.
|
||||
fn read_counting_tilings(body: &str) -> (crate::Imported, u64) {
|
||||
read_counting(body, NEVER_ENDS)
|
||||
}
|
||||
|
||||
/// [`read_within`], with how many patterns the tiling pattern lookups compared while reading.
|
||||
fn read_counting(body: &str, secs: u64) -> (crate::Imported, u64) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ps = eps(body);
|
||||
std::thread::spawn(move || tx.send(import(&ps)).ok());
|
||||
rx.recv_timeout(std::time::Duration::from_secs(secs)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap()
|
||||
std::thread::spawn(move || tx.send((import(&ps), tilings_compared())).ok());
|
||||
let (read, compared) = rx.recv_timeout(std::time::Duration::from_secs(secs)).unwrap_or_else(|_| panic!("reading {body} did not finish"));
|
||||
(read.unwrap(), compared)
|
||||
}
|
||||
|
||||
/// Runs the program of `body` in an interpreter of its own on the 100 × 100 page of [`eps`]'s
|
||||
/// bounding box, and returns what `look` finds in the interpreter when the program has ended. It
|
||||
/// does not read the file's comments or make a document. The test fails when the program stops on
|
||||
/// an error.
|
||||
fn run_looking<T>(body: &str, look: fn(&Interp) -> T) -> T {
|
||||
let file = eps(body);
|
||||
let ps = super::program(&file).unwrap();
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
doc.layers.clear();
|
||||
let page = vectorcraft_geom::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]);
|
||||
let mut it = Interp::new(ps, GState::default(), Out::new(doc, page, Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
assert!(it.run().is_ok(), "running {body} failed");
|
||||
let found = look(&it);
|
||||
it.release();
|
||||
found
|
||||
}
|
||||
|
||||
/// Reads `body` after a square: the square stays, and the program runs out of the operation
|
||||
|
||||
@@ -594,7 +594,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"window.panel",
|
||||
"Show Panel",
|
||||
"",
|
||||
"{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was",
|
||||
"{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that can be seen, and shows it again where it was; with the dock hidden by Tab or Shift+Tab, the dock shows again with the panel in front, leaving the toolbar and Control bar as they are; fails and changes nothing in Presentation Mode, which hides every panel",
|
||||
),
|
||||
(
|
||||
"window.panel.layout",
|
||||
|
||||
@@ -1244,8 +1244,11 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>,
|
||||
match o.id {
|
||||
Some(id) => {
|
||||
let mut n = originals.remove(&id).ok_or_else(|| {
|
||||
let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" };
|
||||
out_err(&at, format!("{} {why}", id.0))
|
||||
if wanted.contains(&id) {
|
||||
out_err(&at, format!("{} is listed twice", id.0))
|
||||
} else {
|
||||
out_err(&at, format!("{} is not the id of an input object", id.0))
|
||||
}
|
||||
})?;
|
||||
// Type, images and symbol instances are only read: writing one would make it a path.
|
||||
if input_kind(&n).is_some() && !o.kept {
|
||||
|
||||
@@ -307,6 +307,36 @@ fn drop_shadow_darkens_outside_the_shape() {
|
||||
assert_eq!(lum(img.pixel(15, 15)), WHITE);
|
||||
}
|
||||
|
||||
/// A Transform effect listed after a Drop Shadow transforms the shadow with the art: halving a
|
||||
/// 40 pt square about its center halves the shadow's 10 pt offset, with Scale Strokes & Effects
|
||||
/// off or on, and for a group as for a path.
|
||||
#[test]
|
||||
fn a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art() {
|
||||
let r = Rect::new(20.0, 20.0, 60.0, 60.0);
|
||||
let shadow = ("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"}));
|
||||
for strokes in [false, true] {
|
||||
let transform = ("distort.transform", json!({"scaleH": 50, "scaleV": 50, "strokes": strokes}));
|
||||
let path = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![shadow.clone(), transform.clone()]);
|
||||
let mut group = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![]);
|
||||
let l = group.layers[0].id;
|
||||
let square = group.layers[0].children().and_then(|c| c.first()).cloned().unwrap();
|
||||
group.remove(square.id).unwrap();
|
||||
let mut g = Node::group(group.alloc_id(), vec![square]);
|
||||
g.appearance.effects = [shadow.clone(), transform].into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect();
|
||||
group.insert(Some(l), 0, g).unwrap();
|
||||
for (what, d) in [("path", path), ("group", group)] {
|
||||
let img = render(&d);
|
||||
// The square now covers 30..50 and its shadow 35..55.
|
||||
assert_eq!(img.pixel(40, 40), [255, 0, 0, 255], "{what}, Scale Strokes & Effects {strokes}");
|
||||
assert!(lum(img.pixel(53, 37)) < 100, "{what}, {strokes}: the shadow reaches up to 35: {:?}", img.pixel(53, 37));
|
||||
assert_eq!(lum(img.pixel(57, 57)), WHITE, "{what}, {strokes}: the shadow ends at 55");
|
||||
// The area redrawn for it takes in the 5 pt shadow around the 30..50 square.
|
||||
let b = super::cull_bounds(&d.symbol_boxes(), &d.layers[0].children().unwrap()[0]).unwrap();
|
||||
assert!((b.x0 - 25.0).abs() < 1e-9 && (b.x1 - 55.0).abs() < 1e-9, "{what}, {strokes}: {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A red square's drop shadow at Darkness `darkness` (%), opaque and sharp, 10 pt down and
|
||||
/// right; `shade` "color" or "darkness".
|
||||
fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkness: f64) -> Document {
|
||||
|
||||
@@ -26,6 +26,7 @@ mod nodes;
|
||||
mod output;
|
||||
mod paint;
|
||||
mod text;
|
||||
mod variables;
|
||||
|
||||
use nodes::SymbolDef;
|
||||
pub(crate) use nodes::{MASK_FLAGS, Reuse};
|
||||
@@ -117,8 +118,10 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
||||
layer_nest: 0,
|
||||
non_latin1_names: false,
|
||||
foreign_ns: BTreeMap::new(),
|
||||
bound: HashMap::new(),
|
||||
};
|
||||
w.assign_name_ids();
|
||||
w.assign_bound_ids();
|
||||
for layer in &doc.layers {
|
||||
w.count_colorized_images(layer);
|
||||
}
|
||||
@@ -135,6 +138,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
||||
w.line("</g>");
|
||||
}
|
||||
|
||||
// Include Variable Data, once the body has written the bound objects' ids.
|
||||
let variables = w.variable_sets();
|
||||
// XML names cannot use character references. Preserve names that Latin-1 cannot encode.
|
||||
let encoding = if opts.encoding == Encoding::Latin1 && w.non_latin1_names {
|
||||
w.warn("Unicode data attribute names require UTF-8; SVG was written in UTF-8");
|
||||
@@ -207,6 +212,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
||||
out.push_str(&w.indent(1));
|
||||
out.push_str(&format!("<script xlink:href=\"{}\"/>{nl}", xml_attr_escape(file)));
|
||||
}
|
||||
out.push_str(&variables);
|
||||
let latin1 = encoding == Encoding::Latin1;
|
||||
if latin1 {
|
||||
latin1_refs(&mut out);
|
||||
@@ -302,4 +308,7 @@ struct Writer<'a> {
|
||||
/// The prefix written for each namespace of other applications' attributes, by its URI
|
||||
/// ([`Writer::foreign_attrs`]): declared on the root.
|
||||
foreign_ns: BTreeMap<String, String>,
|
||||
/// Include Variable Data: the objects text variables are bound to, with the element id each
|
||||
/// was written with, once written ([`Writer::note_bound`]).
|
||||
bound: HashMap<NodeId, variables::Bound>,
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ pub(super) fn valid_xml_value(s: &str) -> bool {
|
||||
}
|
||||
|
||||
/// An XML name without a colon (an NCName): a letter or `_`, then [`xml_name_char`]s.
|
||||
fn xml_ncname(s: &str) -> bool {
|
||||
pub(super) fn xml_ncname(s: &str) -> bool {
|
||||
let mut chars = s.chars();
|
||||
chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(xml_name_char)
|
||||
}
|
||||
@@ -193,6 +193,9 @@ impl Writer<'_> {
|
||||
return String::new();
|
||||
}
|
||||
let mut out = self.names.get(&n.id).map_or_else(String::new, |id| format!(" id=\"{}\"", xml_escape(id)));
|
||||
if let Some(id) = self.names.get(&n.id).filter(|_| !self.bound.is_empty()).cloned() {
|
||||
self.note_bound(n.id, &id);
|
||||
}
|
||||
if let (Some(id), Some(name)) = (self.names.get(&n.id), n.name.as_deref())
|
||||
&& name != id
|
||||
{
|
||||
|
||||
@@ -165,6 +165,7 @@ impl Writer<'_> {
|
||||
}
|
||||
}
|
||||
let id = self.id_attr(n);
|
||||
self.note_outlined(n.id);
|
||||
let a = self.attrs(&css::transparency(n));
|
||||
// Text outlined because SVG text can't draw it (bent glyphs, vertical type, ruby, effects on
|
||||
// a path and the like) names its text for screen readers and search; Convert to Outlines
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Include Variable Data: the document's text variables, its data sets and the object each text
|
||||
//! variable is bound to, written as a `variableSets` element in the layout Load Variable Library
|
||||
//! reads (`variableSets` › `variableSet` › `variables` of `variable` elements, and
|
||||
//! `sampleDataSets` of `sampleDataSet` elements). A variable's bound object is named by its
|
||||
//! element's id in a `docRef` attribute, `docRef="id('…')"`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use vectorcraft_doc::{DataValue, Document, NodeId, NodeKind, VariableKind};
|
||||
|
||||
use super::Writer;
|
||||
use super::output::{valid_xml_value, xml_attr_escape, xml_ncname, xml_text};
|
||||
|
||||
/// The namespace of a variable library's elements.
|
||||
const NS_VARIABLES: &str = "http://ns.adobe.com/Variables/1.0/"; // brand-ok: the namespace variable libraries use
|
||||
|
||||
/// The default namespace inside `sampleDataSets`, which the elements holding values are in.
|
||||
const NS_CUSTOM: &str = "http://ns.adobe.com/GenericCustomNamespace/1.0/"; // brand-ok: the namespace variable libraries use
|
||||
|
||||
/// The `category` of a text variable.
|
||||
const NS_FLOWS: &str = "http://ns.adobe.com/Flows/1.0/"; // brand-ok: the namespace variable libraries use
|
||||
|
||||
/// The most variable data a file holds: the largest variable library Load Variable Library reads.
|
||||
const MAX_VARIABLE_DATA: usize = 8 << 20;
|
||||
|
||||
/// The most variables a variable library Load Variable Library reads declares.
|
||||
const MAX_VARIABLES: usize = 256;
|
||||
|
||||
/// The most data sets a variable library Load Variable Library reads holds.
|
||||
const MAX_DATASETS: usize = 1_000;
|
||||
|
||||
/// The most XML nodes (elements and text) a variable library Load Variable Library reads holds.
|
||||
const MAX_NODES: usize = 2_000_000;
|
||||
|
||||
/// The text variables of `doc` that a variable library can hold: those whose names can name an
|
||||
/// element, the first [`MAX_VARIABLES`] of them.
|
||||
fn written_variables(doc: &Document) -> Vec<&str> {
|
||||
nameable_variables(doc).take(MAX_VARIABLES).collect()
|
||||
}
|
||||
|
||||
/// The text variables of `doc` whose names can name an element.
|
||||
fn nameable_variables(doc: &Document) -> impl Iterator<Item = &str> {
|
||||
doc.variables.variables.iter().filter(|v| v.kind == VariableKind::Text && xml_ncname(&v.name)).map(|v| v.name.as_str())
|
||||
}
|
||||
|
||||
/// The object each text variable of `doc` that a library can hold names: the first of its bound
|
||||
/// objects (by object id) that is a type object and that no variable before it names.
|
||||
fn bound_objects(doc: &Document) -> Vec<(&str, NodeId)> {
|
||||
let mut taken = HashSet::new();
|
||||
written_variables(doc)
|
||||
.into_iter()
|
||||
.filter_map(|name| {
|
||||
let id = doc
|
||||
.variables
|
||||
.objects_of(name)
|
||||
.into_iter()
|
||||
.find(|id| !taken.contains(id) && doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))))?;
|
||||
taken.insert(id);
|
||||
Some((name, id))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
/// With Include Variable Data, give each object a text variable names an id when its name gave
|
||||
/// it none (an unnamed object, or Minimal object ids). Called after the named objects' ids are
|
||||
/// taken.
|
||||
pub(super) fn assign_bound_ids(&mut self) {
|
||||
if !self.opts.variable_data {
|
||||
return;
|
||||
}
|
||||
let doc = self.doc;
|
||||
for (name, id) in bound_objects(doc) {
|
||||
if !self.names.contains_key(&id) {
|
||||
let cand = self.unique_id(&super::sanitize_id(name));
|
||||
self.names.insert(id, cand);
|
||||
}
|
||||
self.bound.insert(id, Bound::Pending);
|
||||
}
|
||||
}
|
||||
|
||||
/// Note that the object `id` was written with the element id `element`, when a variable names
|
||||
/// it (the first element written for it).
|
||||
pub(super) fn note_bound(&mut self, id: NodeId, element: &str) {
|
||||
if let Some(slot) = self.bound.get_mut(&id)
|
||||
&& *slot == Bound::Pending
|
||||
{
|
||||
*slot = Bound::Written(element.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Note that the type object `id` was written as outlines, which open as art rather than type.
|
||||
pub(super) fn note_outlined(&mut self, id: NodeId) {
|
||||
if let Some(slot) = self.bound.get_mut(&id) {
|
||||
*slot = Bound::Outlined;
|
||||
}
|
||||
}
|
||||
|
||||
/// The `variableSets` element, indented one level, or nothing when the option is off or the
|
||||
/// document has no text variable a library can hold. Variables of other kinds, names that
|
||||
/// can't name an element, and data sets whose names are blank, have white space at either end,
|
||||
/// hold characters XML can't hold or repeat a name are left out, each kind with a warning. A
|
||||
/// variable whose object this file doesn't hold, or holds as outlines, is written without a
|
||||
/// `docRef` (outlines with a warning). Characters XML can't hold are left out of the values,
|
||||
/// with a warning. Variable data larger than Load Variable Library reads
|
||||
/// ([`MAX_VARIABLE_DATA`]) is left out with a warning.
|
||||
pub(super) fn variable_sets(&mut self) -> String {
|
||||
let doc = self.doc;
|
||||
let vars = &doc.variables;
|
||||
if !self.opts.variable_data || vars.variables.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if vars.variables.iter().any(|v| v.kind != VariableKind::Text) {
|
||||
self.warn("Variables that aren't text variables were left out of the SVG's variable data");
|
||||
}
|
||||
if vars.variables.iter().any(|v| v.kind == VariableKind::Text && !xml_ncname(&v.name)) {
|
||||
self.warn("Text variables whose names can't name an XML element were left out of the SVG's variable data");
|
||||
}
|
||||
let names = written_variables(doc);
|
||||
if names.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if nameable_variables(doc).nth(MAX_VARIABLES).is_some() {
|
||||
self.warn("Text variables past the first 256, which Load Variable Library reads, were left out of the SVG's variable data");
|
||||
}
|
||||
// XML names can't be written as character references.
|
||||
self.non_latin1_names |= names.iter().any(|n| n.chars().any(|c| u32::from(c) > 0xff));
|
||||
let bound = bound_objects(doc);
|
||||
let slots: Vec<Option<&Bound>> =
|
||||
names.iter().map(|name| bound.iter().find(|(n, _)| n == name).and_then(|(_, id)| self.bound.get(id))).collect();
|
||||
let outlined = slots.contains(&Some(&Bound::Outlined));
|
||||
let refs: Vec<Option<String>> = slots
|
||||
.into_iter()
|
||||
.map(|s| match s {
|
||||
Some(Bound::Written(e)) => Some(e.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if outlined {
|
||||
self.warn("Type written as outlines opens as art: its text variables were written unbound in the SVG's variable data");
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
let mut datasets: Vec<_> = vars
|
||||
.datasets
|
||||
.iter()
|
||||
.filter(|ds| !ds.name.is_empty() && ds.name.trim() == ds.name && valid_xml_value(&ds.name) && seen.insert(ds.name.as_str()))
|
||||
.collect();
|
||||
if datasets.len() < vars.datasets.len() {
|
||||
self.warn(
|
||||
"Data sets whose names are blank, start or end with white space, hold characters XML can't hold or repeat a name were left out of the SVG's variable data",
|
||||
);
|
||||
}
|
||||
if datasets.len() > MAX_DATASETS {
|
||||
datasets.truncate(MAX_DATASETS);
|
||||
self.warn("Data sets past the first 1,000, which Load Variable Library reads, were left out of the SVG's variable data");
|
||||
}
|
||||
let values = || datasets.iter().flat_map(|ds| names.iter().filter_map(|n| ds.values.get(*n)));
|
||||
if values().any(|v| matches!(v, DataValue::Text(t) if !valid_xml_value(t))) {
|
||||
self.warn("Characters XML can't hold were left out of data set values in the SVG's variable data");
|
||||
}
|
||||
let nl = if self.opts.minify { "" } else { "\n" };
|
||||
let mut out = String::new();
|
||||
// The XML nodes the reader makes: each start tag an element, the white space before each
|
||||
// line a text node unless minified, and each paragraph's text another.
|
||||
let mut nodes = 0usize;
|
||||
let mut line = |depth: usize, s: &str| {
|
||||
nodes += usize::from(!s.starts_with("</")) + usize::from(!self.opts.minify) + usize::from(s.starts_with("<p>") && s != "<p></p>");
|
||||
out.push_str(&self.indent(depth));
|
||||
out.push_str(s);
|
||||
out.push_str(nl);
|
||||
};
|
||||
line(1, &format!("<variableSets xmlns=\"{NS_VARIABLES}\">"));
|
||||
line(2, "<variableSet varSetName=\"binding1\" locked=\"none\">");
|
||||
line(3, "<variables>");
|
||||
for (name, element) in names.iter().zip(&refs) {
|
||||
let doc_ref = element.as_ref().map_or_else(String::new, |e| format!(" docRef=\"id('{}')\"", xml_attr_escape(e)));
|
||||
line(4, &format!("<variable varName=\"{name}\" trait=\"textcontent\" category=\"{NS_FLOWS}\"{doc_ref}></variable>"));
|
||||
}
|
||||
line(3, "</variables>");
|
||||
line(3, &format!("<v:sampleDataSets xmlns:v=\"{NS_VARIABLES}\" xmlns=\"{NS_CUSTOM}\">"));
|
||||
for ds in datasets {
|
||||
line(4, &format!("<v:sampleDataSet dataSetName=\"{}\">", xml_attr_escape(&ds.name)));
|
||||
for name in &names {
|
||||
let Some(DataValue::Text(t)) = ds.values.get(*name) else { continue };
|
||||
line(5, &format!("<{name}>"));
|
||||
// One paragraph per line; a carriage return as a reference, which reads back as itself.
|
||||
for para in t.split('\n') {
|
||||
line(6, &format!("<p>{}</p>", xml_text(para).replace('\r', " ")));
|
||||
}
|
||||
line(5, &format!("</{name}>"));
|
||||
}
|
||||
line(4, "</v:sampleDataSet>");
|
||||
}
|
||||
line(3, "</v:sampleDataSets>");
|
||||
line(2, "</variableSet>");
|
||||
line(1, "</variableSets>");
|
||||
// Measured as written: a Latin-1 file holds other characters as references.
|
||||
let mut written = std::borrow::Cow::Borrowed(out.as_str());
|
||||
if self.opts.encoding == crate::Encoding::Latin1 {
|
||||
super::latin1_refs(written.to_mut());
|
||||
}
|
||||
if written.len() > MAX_VARIABLE_DATA {
|
||||
self.warn("The variable data is larger than 8 MiB, which Load Variable Library reads, and was left out of the SVG");
|
||||
return String::new();
|
||||
}
|
||||
if nodes > MAX_NODES {
|
||||
self.warn("The variable data holds more than 2,000,000 XML nodes, which Load Variable Library reads, and was left out of the SVG");
|
||||
return String::new();
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// What an object a text variable names became in the file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Bound {
|
||||
/// Not written yet.
|
||||
Pending,
|
||||
/// Written as an element with this id.
|
||||
Written(String),
|
||||
/// Written as outlines, which open as art.
|
||||
Outlined,
|
||||
}
|
||||
@@ -117,6 +117,17 @@ pub(crate) fn svg_filters(svg: &str, room: usize, taken: impl Fn(&str) -> bool)
|
||||
|
||||
/// The document SVG text `svg` holds, read with `opts`, and the warnings about what it couldn't keep.
|
||||
pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Result<(Document, Vec<String>), SvgError> {
|
||||
import_finding(svg, opts, max_nodes, &HashSet::new()).map(|(doc, warnings, _)| (doc, warnings))
|
||||
}
|
||||
|
||||
/// [`import`], also finding the object made of each element whose id `wanted` lists (the first
|
||||
/// object named after it), by element id.
|
||||
pub(crate) fn import_finding(
|
||||
svg: &str,
|
||||
opts: &ImportOptions,
|
||||
max_nodes: usize,
|
||||
wanted: &HashSet<String>,
|
||||
) -> Result<(Document, Vec<String>, crate::Found), SvgError> {
|
||||
// Before anything reads the tree: the XML reader, usvg, and what is made of its tree, all by
|
||||
// recursion.
|
||||
let too_deep = || SvgError::TooDeep(depth::MAX_ELEMENT_DEPTH);
|
||||
@@ -191,6 +202,8 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul
|
||||
max_nodes,
|
||||
error: None,
|
||||
nested_svg_images: 0,
|
||||
wanted: wanted.clone(),
|
||||
found: HashMap::new(),
|
||||
};
|
||||
im.doc.svg_scripts = found.scripts;
|
||||
if im.files.nested_text() {
|
||||
@@ -303,7 +316,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul
|
||||
}
|
||||
}
|
||||
im.doc.limit_depth_noting(&mut im.warnings);
|
||||
Ok((im.doc, im.warnings))
|
||||
Ok((im.doc, im.warnings, im.found))
|
||||
}
|
||||
|
||||
struct Importer {
|
||||
@@ -365,4 +378,8 @@ struct Importer {
|
||||
error: Option<SvgError>,
|
||||
/// How many nested SVG images were converted so far.
|
||||
nested_svg_images: usize,
|
||||
/// The element ids whose objects [`import_finding`] looks for.
|
||||
wanted: HashSet<String>,
|
||||
/// The object made of each element `wanted` lists, by its id.
|
||||
found: HashMap<String, NodeId>,
|
||||
}
|
||||
|
||||
@@ -140,12 +140,16 @@ impl Importer {
|
||||
Some(self.doc.alloc_id())
|
||||
}
|
||||
|
||||
/// A new object named after element `id` (unless it has none or a made-up one).
|
||||
/// A new object named after element `id` (unless it has none or a made-up one), noted as
|
||||
/// found when [`Importer::wanted`] lists `id`.
|
||||
fn named(&mut self, id: &str, kind: NodeKind) -> Option<Node> {
|
||||
let nid = self.alloc_id()?;
|
||||
let mut n = Node::new(nid, kind);
|
||||
if !id.is_empty() && !made_up(id) {
|
||||
n.name = Some(self.name_of(id).to_string());
|
||||
if self.wanted.contains(id) {
|
||||
self.found.entry(id.to_string()).or_insert(nid);
|
||||
}
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
@@ -861,6 +865,8 @@ mod tests {
|
||||
max_nodes: 1000,
|
||||
error: None,
|
||||
nested_svg_images: crate::MAX_NESTED_SVG_IMAGES,
|
||||
wanted: HashSet::new(),
|
||||
found: HashMap::new(),
|
||||
};
|
||||
let tree = usvg::Tree::from_str(r#"<svg xmlns="http://www.w3.org/2000/svg"/>"#, &usvg::Options::default()).unwrap();
|
||||
let res = im.svg_image(&tree, Affine::IDENTITY);
|
||||
|
||||
@@ -246,6 +246,10 @@ pub struct ExportOptions {
|
||||
/// its licence allows (whole where it forbids subsetting, left out where it forbids
|
||||
/// embedding). Not with outlined text or the Tiny profile.
|
||||
pub embed_fonts: bool,
|
||||
/// Include Variable Data: write the document's text variables, its data sets and the object
|
||||
/// each text variable is bound to as a `variableSets` element, in the layout Load Variable
|
||||
/// Library reads. Opening the SVG reads them back.
|
||||
pub variable_data: bool,
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
@@ -267,6 +271,7 @@ impl Default for ExportOptions {
|
||||
encoding: Encoding::Utf8,
|
||||
profile: Profile::Svg11,
|
||||
embed_fonts: false,
|
||||
variable_data: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -577,6 +582,15 @@ pub fn import_within(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul
|
||||
vectorcraft_doc::depth::with_deep_stack(|| import::import(svg, opts, max_nodes))
|
||||
}
|
||||
|
||||
/// [`import_with`], also returning the object made of each element whose id `ids` lists (the
|
||||
/// first object named after it), by element id. Ids with no object are left out.
|
||||
pub fn import_finding(svg: &str, opts: &ImportOptions, ids: &std::collections::HashSet<String>) -> Result<(Document, Vec<String>, Found), SvgError> {
|
||||
vectorcraft_doc::depth::with_deep_stack(|| import::import_finding(svg, opts, MAX_NODES, ids))
|
||||
}
|
||||
|
||||
/// The object made of each element [`import_finding`] looked for, by element id.
|
||||
pub type Found = std::collections::HashMap<String, vectorcraft_doc::NodeId>;
|
||||
|
||||
/// Reads a linked file: its bytes and the link to it (absolute path, size, time and hash), or
|
||||
/// `None` when there is no readable file at the (absolute) path.
|
||||
pub type ReadFile<'a> = &'a (dyn Fn(&str) -> Option<(Vec<u8>, vectorcraft_doc::LinkInfo)> + Sync);
|
||||
|
||||
@@ -100,9 +100,10 @@ fn continues_word(g: &SGlyph, next: Option<&SGlyph>) -> bool {
|
||||
|
||||
/// A glyph letter spacing follows: a visible glyph followed by another character in the same word
|
||||
/// (`next` is the next glyph that is not a format control, or a zero-width space). The glyphs of
|
||||
/// one shaped cluster (a letter and its combining marks) take none between them.
|
||||
/// one shaped cluster (a letter and its combining marks) take none between them, and neither do the
|
||||
/// two halves of an inseparable pair that Bunri-Kinshi keeps together.
|
||||
fn letter_gap(g: &SGlyph, next: Option<&SGlyph>) -> bool {
|
||||
gap_owner(g) && continues_word(g, next)
|
||||
gap_owner(g) && !g.keeps_next && continues_word(g, next)
|
||||
}
|
||||
|
||||
/// The width of a word space in `g`'s font and size: what letter spacing is a share of.
|
||||
|
||||
@@ -694,6 +694,7 @@ mod emit_kashida_mark_last {
|
||||
inline: None,
|
||||
word_end: false,
|
||||
keeps_word: false,
|
||||
keeps_next: false,
|
||||
lead: 0.0,
|
||||
proportional: false,
|
||||
vpal: None,
|
||||
@@ -758,6 +759,7 @@ mod emit_kashida_mark_last {
|
||||
inline: None,
|
||||
word_end: false,
|
||||
keeps_word: false,
|
||||
keeps_next: false,
|
||||
lead: 0.0,
|
||||
proportional: false,
|
||||
vpal: None,
|
||||
|
||||
@@ -347,10 +347,11 @@ pub(super) fn break_line(
|
||||
while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte && (next.len == 0) == (gl.len == 0)) {
|
||||
cluster_end += 1;
|
||||
}
|
||||
// No Break: the marked characters stay on one line, including across a space inside the run.
|
||||
if gl.no_break && gl.ch != '\u{2028}' {
|
||||
// No Break: the marked characters stay on one line, including across a space inside the run;
|
||||
// Bunri-Kinshi keeps an inseparable pair, or a chain of them, on one line too.
|
||||
if gl.ch != '\u{2028}' {
|
||||
while let Some(n) = g.get(cluster_end) {
|
||||
if !n.no_break || n.ch == '\u{2028}' {
|
||||
if !splits_no_break(g, cluster_end) || n.ch == '\u{2028}' {
|
||||
break;
|
||||
}
|
||||
let b = n.byte;
|
||||
@@ -550,9 +551,10 @@ fn may_end_after(g: &[SGlyph], end: usize, kinsoku: Kinsoku) -> bool {
|
||||
last.break_after() && last.len > 0 && !last.is_soft_hyphen() && kinsoku_allows(g, e - 1, kinsoku) && !splits_no_break(g, e)
|
||||
}
|
||||
|
||||
/// Would a line ending at glyph index `end` split a No Break run?
|
||||
/// Would a line ending at glyph index `end` split a No Break run, or an inseparable pair that
|
||||
/// Bunri-Kinshi keeps together ([`SGlyph::keeps_next`])?
|
||||
fn splits_no_break(g: &[SGlyph], end: usize) -> bool {
|
||||
end > 0 && g.get(end - 1).is_some_and(|a| a.no_break) && g.get(end).is_some_and(|b| b.no_break)
|
||||
end > 0 && g.get(end - 1).is_some_and(|a| (a.no_break && g.get(end).is_some_and(|b| b.no_break)) || (a.keeps_next && g.get(end).is_some()))
|
||||
}
|
||||
|
||||
/// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a
|
||||
@@ -622,6 +624,7 @@ fn push_in(
|
||||
k >= g.len()
|
||||
|| (g[k].byte != g[k - 1].byte
|
||||
&& kinsoku_allows(g, k - 1, kinsoku)
|
||||
&& !splits_no_break(g, k)
|
||||
&& (g[k].is_space() || (g[k - 1].break_after() && !g[k - 1].is_soft_hyphen())))
|
||||
};
|
||||
let (mut end, mut x) = (cluster_end, x);
|
||||
@@ -686,12 +689,14 @@ fn push_in(
|
||||
|
||||
/// May a push-in close the gap after glyph `a`? A gap between two characters, at least one of them
|
||||
/// CJK, outside a tate-chu-yoko block, a list marker and a shaped cluster, and not next to a space,
|
||||
/// a tab or an inline graphic before it. Zero-width format controls take no gap: the gap is the
|
||||
/// one between the visible characters on either side, before `end`, as the layout justifies it.
|
||||
/// a tab or an inline graphic before it, nor inside an inseparable pair that Bunri-Kinshi keeps
|
||||
/// together. Zero-width format controls take no gap: the gap is the one between the visible
|
||||
/// characters on either side, before `end`, as the layout justifies it.
|
||||
fn closes(g: &[SGlyph], a: usize, end: usize) -> bool {
|
||||
let Some(p) = g.get(a).filter(|p| !is_format_control(p.ch)) else { return false };
|
||||
let Some(n) = g.get(a + 1..end).and_then(|rest| rest.iter().find(|n| !is_format_control(n.ch))) else { return false };
|
||||
p.len > 0
|
||||
&& !p.keeps_next
|
||||
&& p.byte != n.byte
|
||||
&& p.inline.is_none()
|
||||
&& !p.is_space()
|
||||
|
||||
@@ -11,7 +11,7 @@ use super::{
|
||||
fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics,
|
||||
};
|
||||
use crate::justify::is_format_control;
|
||||
use crate::shape::keep_korean_words;
|
||||
use crate::shape::{keep_inseparable_pairs, keep_korean_words};
|
||||
|
||||
/// Does paragraph `para` push characters in (Kinsoku Shori Type Push In First or Push Out First,
|
||||
/// with kinsoku on)?
|
||||
@@ -230,6 +230,10 @@ fn prepare_paragraph<'a, 'text>(
|
||||
if !para.korean_line_break_anywhere {
|
||||
keep_korean_words(&mut sg);
|
||||
}
|
||||
// Bunri-Kinshi keeps inseparable pairs on one line, while kinsoku is on.
|
||||
if para.bunri_kinshi && para.kinsoku != Kinsoku::None {
|
||||
keep_inseparable_pairs(text, &mut sg);
|
||||
}
|
||||
// Japanese composition: consecutive punctuation shares one half-em space.
|
||||
let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] };
|
||||
// …and a quarter em between Japanese and Latin text.
|
||||
@@ -736,16 +740,37 @@ fn gap_pair<'a>(glyph: usize, held: &[usize], after: &[Option<usize>], index: us
|
||||
fn is_cjk_gap(glyph: usize, held: &[usize], after: &[Option<usize>], index: usize, sg: &[SGlyph]) -> bool {
|
||||
gap_pair(glyph, held, after, index, sg).is_some_and(|(a, b)| {
|
||||
a.byte != b.byte && !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))
|
||||
})
|
||||
}) && !in_pair(glyph, index, after, sg)
|
||||
}
|
||||
|
||||
// Bunri-Kinshi: are glyph `glyph` and the glyph drawn after it in the two halves of an inseparable
|
||||
// pair? Justification adds no space between them. The halves are neighboring clusters: the
|
||||
// earlier one's last glyph keeps the next, and the later one starts right after it. Right to left,
|
||||
// the clusters are drawn in the other order, each with its glyphs in logical order, so the two
|
||||
// drawn neighbors need not be neighbors in `sg`.
|
||||
fn in_pair(glyph: usize, index: usize, after: &[Option<usize>], sg: &[SGlyph]) -> bool {
|
||||
let Some(next) = drawn_after(index, glyph, after) else {
|
||||
return false;
|
||||
};
|
||||
let (Some(a), Some(b)) = (sg.get(glyph), sg.get(next)) else {
|
||||
return false;
|
||||
};
|
||||
if a.byte == b.byte {
|
||||
return false;
|
||||
}
|
||||
let (first, later) = if a.byte < b.byte { (glyph, b.byte) } else { (next, a.byte) };
|
||||
let byte = sg.get(first).map_or(0, |g| g.byte);
|
||||
let last = first + sg.iter().skip(first + 1).take_while(|g| g.byte == byte).count();
|
||||
sg.get(last).is_some_and(|g| g.keeps_next) && sg.get(last + 1).is_some_and(|g| g.byte == later)
|
||||
}
|
||||
|
||||
// Single Word Justification's Full Justify spreads a line's leftover room over the gaps between
|
||||
// its drawn glyphs: not after a held glyph, nor inside a tate-chu-yoko block (one cell) or a
|
||||
// shaped cluster, whose glyphs (a letter and its combining marks, a conjunct and its matra) stay
|
||||
// together. On a line that ends in a drawn hyphen, the gap before the hyphen counts too: it follows
|
||||
// the line's last visible glyph.
|
||||
// its drawn glyphs: not after a held glyph, nor inside a tate-chu-yoko block (one cell), a shaped
|
||||
// cluster, whose glyphs (a letter and its combining marks, a conjunct and its matra) stay
|
||||
// together, or an inseparable pair. On a line that ends in a drawn hyphen, the gap before the
|
||||
// hyphen counts too: it follows the line's last visible glyph.
|
||||
fn is_letter_gap(glyph: usize, held: &[usize], after: &[Option<usize>], index: usize, sg: &[SGlyph], hyphen: bool) -> bool {
|
||||
if held.contains(&glyph) || sg.get(glyph).is_none_or(|a| is_format_control(a.ch)) {
|
||||
if held.contains(&glyph) || sg.get(glyph).is_none_or(|a| is_format_control(a.ch)) || in_pair(glyph, index, after, sg) {
|
||||
return false;
|
||||
}
|
||||
match gap_pair(glyph, held, after, index, sg) {
|
||||
|
||||
@@ -36,6 +36,7 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec<SGlyph> {
|
||||
inline: None,
|
||||
word_end: false,
|
||||
keeps_word: false,
|
||||
keeps_next: false,
|
||||
lead: 0.0,
|
||||
proportional: false,
|
||||
vpal: None,
|
||||
@@ -629,3 +630,89 @@ fn korean_words_take_no_hyphen() {
|
||||
assert_eq!(hyphens(true), 1, "on, the word's Latin letters hyphenate as before");
|
||||
});
|
||||
}
|
||||
|
||||
/// The glyphs of `text`, each 10 pt wide, with its inseparable pairs kept together when `bunri`
|
||||
/// (Bunri-Kinshi on).
|
||||
fn pairs(text: &str, bunri: bool) -> Vec<SGlyph> {
|
||||
let mut glyphs = full_width(text);
|
||||
if bunri {
|
||||
crate::shape::keep_inseparable_pairs(text, &mut glyphs);
|
||||
}
|
||||
glyphs
|
||||
}
|
||||
|
||||
/// Bunri-Kinshi: both composers never break inside two em dashes, two ellipses, two two-dot
|
||||
/// leaders or a vertical kana repeat mark, nor inside a chain of em dashes; off, the line may break
|
||||
/// between them where it could before. Unlike neighbors and characters on either side of a forced
|
||||
/// line break are not a pair.
|
||||
#[test]
|
||||
fn bunri_kinshi_keeps_jlreq_pairs_in_both_composers() {
|
||||
use KinsokuShoriType::PushOutOnly;
|
||||
let ends = |text: &str, bunri: bool| candidates(text, &pairs(text, bunri), None, Kinsoku::Hard).iter().map(|b| b.end).collect::<Vec<_>>();
|
||||
let text = "あい——う〳〵え";
|
||||
assert_eq!(ends(text, false), [1, 2, 3, 4, 5, 6, 7]);
|
||||
assert_eq!(ends(text, true), [1, 2, 4, 5, 7]);
|
||||
assert_eq!(ends("———あ", false), [1, 2, 3]);
|
||||
assert_eq!(ends("———あ", true), [3], "a chain stays together");
|
||||
assert_eq!(ends("あ〴〵い", true), [1, 3]);
|
||||
assert_eq!(ends("—…あ", true), ends("—…あ", false), "unlike neighbors are not a pair");
|
||||
for text in ["あ……い", "あ‥‥い"] {
|
||||
assert!(!ends(text, true).contains(&2), "{text}");
|
||||
}
|
||||
let marked = |text: &str| pairs(text, true).iter().filter(|g| g.keeps_next).count();
|
||||
assert_eq!((marked("……‥‥〳〵〴〵"), marked("—\u{2028}—"), marked("—〵"), marked("〵〳")), (4, 0, 0, 0));
|
||||
// 35 pt holds "あい—" and not "あい——".
|
||||
let greedy =
|
||||
|g: &[SGlyph], width: f64| break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0;
|
||||
assert_eq!(greedy(&pairs(text, false), 35.0), 3, "off: the line ends between the dashes");
|
||||
assert_eq!(greedy(&pairs(text, true), 35.0), 2, "on: both dashes go to the next line");
|
||||
assert_eq!(breaking::word_width(&pairs("——あ", true), 0, &[], Kinsoku::Hard), 20.0);
|
||||
assert_eq!(breaking::word_width(&pairs("——あ", false), 0, &[], Kinsoku::Hard), 10.0);
|
||||
let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 };
|
||||
let on = pairs("あい——う", true);
|
||||
let cands = candidates("あい——う", &on, None, Kinsoku::Hard);
|
||||
assert_eq!(compose(&on, &|_| 35.0, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms), Some(vec![(2, false), (5, false)]));
|
||||
}
|
||||
|
||||
/// Bunri-Kinshi: a pair wider than the line stays whole on it and the next line starts after it;
|
||||
/// every Kinsoku Shori Type and both kinsoku sets leave pairs whole at every width, and each line
|
||||
/// moves on. No Break and a pair next to it stay together as one run.
|
||||
#[test]
|
||||
fn bunri_kinshi_progresses_with_an_overlong_pair_and_no_break() {
|
||||
use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly};
|
||||
let line = |text: &str, g: &[SGlyph], i: usize, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| {
|
||||
break_line(text, g, i, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, false, false, &[]).0
|
||||
};
|
||||
let text = "——あ";
|
||||
assert_eq!(line(text, &pairs(text, true), 0, 15.0, Kinsoku::Hard, PushOutOnly), 2, "the pair overflows the line whole");
|
||||
assert_eq!(line(text, &pairs(text, true), 2, 15.0, Kinsoku::Hard, PushOutOnly), 3);
|
||||
assert_eq!(line(text, &pairs(text, false), 0, 15.0, Kinsoku::Hard, PushOutOnly), 1);
|
||||
let text = "あい——」う〳〵」え";
|
||||
let glyphs = pairs(text, true);
|
||||
for kinsoku in [Kinsoku::Hard, Kinsoku::Soft] {
|
||||
for ty in [PushInFirst, PushOutFirst, PushOutOnly] {
|
||||
for width in (10..=90).step_by(5).map(f64::from) {
|
||||
let mut i = 0;
|
||||
while i < glyphs.len() {
|
||||
let end = line(text, &glyphs, i, width, kinsoku, ty);
|
||||
assert!(end > i, "{kinsoku:?} {ty:?} {width}: no progress at {i}");
|
||||
assert!(end != 3 && end != 7, "{kinsoku:?} {ty:?} {width}: a pair split at {end}");
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = "あ——い";
|
||||
let mut on = pairs(text, true);
|
||||
let mut off = pairs(text, false);
|
||||
for g in on[..2].iter_mut().chain(off[..2].iter_mut()) {
|
||||
g.no_break = true;
|
||||
}
|
||||
assert_eq!(line(text, &on, 0, 25.0, Kinsoku::Hard, PushOutOnly), 3, "No Break and the pair are one run");
|
||||
assert_eq!(line(text, &off, 0, 25.0, Kinsoku::Hard, PushOutOnly), 2);
|
||||
// Push In First closes each gap by up to 1 pt here: 」 needs 3.5 pt, which the four gaps give
|
||||
// and the three outside the pair do not, so on, い moves to the next line with 」.
|
||||
let text = "あ〳〵い」";
|
||||
assert_eq!(line(text, &pairs(text, false), 0, 46.5, Kinsoku::Hard, PushInFirst), 5);
|
||||
assert_eq!(line(text, &pairs(text, true), 0, 46.5, Kinsoku::Hard, PushInFirst), 3);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
|
||||
pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2};
|
||||
pub use layout::{decorations, drawn, fit_first_line, fit_source_lines, layout, layout_with, scaled};
|
||||
pub use shape::{aki_space, line_start_space, spacing_scale};
|
||||
pub use shape::{aki_space, inseparable, line_start_space, spacing_scale};
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
|
||||
pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign};
|
||||
|
||||
@@ -85,6 +85,9 @@ pub(crate) struct SGlyph {
|
||||
/// A letter of a Korean word kept whole (Korean Line Break Anywhere off): a CJK character
|
||||
/// here doesn't let the line break after it ([`keep_korean_words`]).
|
||||
pub keeps_word: bool,
|
||||
/// Bunri-Kinshi (Paragraph panel menu): the glyph's cluster ends with the first character of
|
||||
/// an inseparable pair, so a line may not break after it ([`keep_inseparable_pairs`]).
|
||||
pub keeps_next: bool,
|
||||
/// No Break (Character panel menu): keep this glyph on the same line as the neighbouring
|
||||
/// glyphs that have it.
|
||||
pub no_break: bool,
|
||||
@@ -357,6 +360,36 @@ pub(crate) fn keep_korean_words(sg: &mut [SGlyph]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bunri-Kinshi: mark each glyph of `sg` (a paragraph's glyphs, shaped from `text`, which their
|
||||
/// bytes index) whose cluster ends right before the second character of an inseparable pair
|
||||
/// ([`inseparable`]) as [`SGlyph::keeps_next`], so a line never breaks inside the pair. A pair is
|
||||
/// two neighboring source characters, whatever their fonts and styles; an inline graphic or a
|
||||
/// shaped cluster holding both characters is never a pair.
|
||||
pub(crate) fn keep_inseparable_pairs(text: &str, sg: &mut [SGlyph]) {
|
||||
for k in 1..sg.len() {
|
||||
let (a, b) = (&sg[k - 1], &sg[k]);
|
||||
if a.byte == b.byte || a.inline.is_some() || b.inline.is_some() || a.byte + a.len != b.byte {
|
||||
continue;
|
||||
}
|
||||
let last = text.get(a.byte..b.byte).and_then(|s| s.chars().next_back()).unwrap_or(a.ch);
|
||||
if inseparable(last, b.ch) {
|
||||
sg[k - 1].keeps_next = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `a` followed by `b` an inseparable pair that Bunri-Kinshi keeps on one line? VectorCraft's
|
||||
/// set is the pairs W3C JLREQ lists for its class cl-08 (https://www.w3.org/TR/jlreq/#cl-08): two
|
||||
/// em dashes, two horizontal ellipses, two two-dot leaders, and the upper half of a vertical kana
|
||||
/// repeat mark (U+3033 or U+3034) followed by its lower half (U+3035). File writers call it to
|
||||
/// report whether a paragraph's lines depend on Bunri-Kinshi.
|
||||
pub fn inseparable(a: char, b: char) -> bool {
|
||||
matches!(
|
||||
(a, b),
|
||||
('\u{2014}', '\u{2014}') | ('\u{2026}', '\u{2026}') | ('\u{2025}', '\u{2025}') | ('\u{3033}', '\u{3035}') | ('\u{3034}', '\u{3035}')
|
||||
)
|
||||
}
|
||||
|
||||
/// Is `c` Hangul: a syllable, a jamo or a compatibility jamo (full or half width)?
|
||||
pub(crate) fn is_hangul(c: char) -> bool {
|
||||
c.script() == Script::Hangul
|
||||
@@ -1203,6 +1236,7 @@ fn inline_glyph(
|
||||
inline: Some(InlineBox { art: drawn, xf }),
|
||||
word_end: false,
|
||||
keeps_word: false,
|
||||
keeps_next: false,
|
||||
no_break: st.no_break,
|
||||
vertical_roman: false,
|
||||
cursive: false,
|
||||
@@ -1683,6 +1717,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
inline: None,
|
||||
word_end: false,
|
||||
keeps_word: false,
|
||||
keeps_next: false,
|
||||
no_break: gst.no_break,
|
||||
vertical_roman: gst.standard_vertical_roman,
|
||||
cursive,
|
||||
|
||||
@@ -2553,6 +2553,107 @@ fn korean_words_stay_whole_unless_line_break_anywhere_is_on() {
|
||||
assert_eq!(ends(&t), ["가나다 라".len(), text.len(), second + first, second + text.len()]);
|
||||
}
|
||||
|
||||
/// Bunri-Kinshi (a paragraph attribute): on, two em dashes that do not both fit move to the next
|
||||
/// line together, with either composer and in vertical type; off, or with kinsoku None, the line
|
||||
/// breaks between them. Each paragraph follows its own setting.
|
||||
#[test]
|
||||
fn bunri_kinshi_keeps_two_em_dashes_on_one_line() {
|
||||
let text = "あいう——え";
|
||||
let (split, kept) = ("あいう—".len(), "あいう".len());
|
||||
let measure = |vertical: bool| {
|
||||
let mut t = point("あいう—", style(20.0));
|
||||
t.vertical = vertical;
|
||||
layout(db(), &t).glyphs.iter().map(|g| g.advance).sum::<f64>()
|
||||
};
|
||||
let ends = |t: &TextObject| layout(db(), t).lines.iter().map(|l| l.end).collect::<Vec<_>>();
|
||||
for vertical in [false, true] {
|
||||
let fits = measure(vertical);
|
||||
let frame = if vertical { Rect::new(0.0, 0.0, 400.0, fits + 1.0) } else { Rect::new(0.0, 0.0, fits + 1.0, 400.0) };
|
||||
for composer in [Composer::SingleLine, Composer::EveryLine] {
|
||||
let mut t = area(text, style(20.0), frame, Justify::Left);
|
||||
(t.vertical, t.para.composer) = (vertical, composer);
|
||||
assert_eq!(ends(&t), [split, text.len()], "{composer:?} vertical {vertical}: off");
|
||||
t.para.bunri_kinshi = true;
|
||||
assert_eq!(ends(&t), [kept, text.len()], "{composer:?} vertical {vertical}: on");
|
||||
t.para.kinsoku = vectorcraft_doc::Kinsoku::None;
|
||||
assert_eq!(ends(&t), [split, text.len()], "{composer:?} vertical {vertical}: kinsoku None");
|
||||
}
|
||||
}
|
||||
let fits = measure(false);
|
||||
let mut t = area(&format!("{text}\n{text}"), style(20.0), Rect::new(0.0, 0.0, fits + 1.0, 400.0), Justify::Left);
|
||||
t.para.composer = Composer::SingleLine;
|
||||
let on = vectorcraft_doc::ParaStyle { bunri_kinshi: true, ..t.para.clone() };
|
||||
t.set_paragraph_styles(vec![on, t.para.clone()]);
|
||||
let second = text.len() + 1;
|
||||
assert_eq!(ends(&t), [kept, text.len(), second + split, second + text.len()]);
|
||||
}
|
||||
|
||||
/// Bunri-Kinshi: a justified line spreads its room over the gaps between its CJK characters but not
|
||||
/// over the gap inside a vertical kana repeat mark, and still fills its width.
|
||||
#[test]
|
||||
fn bunri_kinshi_adds_no_justification_space_inside_a_pair() {
|
||||
let text = "あ〳〵いう";
|
||||
let upper = "あ".len();
|
||||
let natural = layout(db(), &point(text, style(20.0))).glyphs.iter().find(|g| g.byte == upper).map(|g| g.advance).unwrap();
|
||||
let frame = Rect::new(0.0, 0.0, 300.0, 400.0);
|
||||
let set = |bunri: bool| {
|
||||
let mut t = area(text, style(20.0), frame, Justify::JustifyAll);
|
||||
t.para.bunri_kinshi = bunri;
|
||||
let l = layout(db(), &t);
|
||||
assert!((l.lines[0].x1 - frame.x1).abs() < 1e-6, "{bunri}: {:?}", l.lines[0]);
|
||||
l.glyphs.iter().find(|g| g.byte == upper).map(|g| g.advance).unwrap()
|
||||
};
|
||||
assert!(set(false) > natural + 1.0, "off: the gap after 〳 widens");
|
||||
assert!((set(true) - natural).abs() < 1e-6, "on: the pair keeps its spacing");
|
||||
// Nor does a Maximum Letter Spacing widen the gap between two em dashes on a line with spaces.
|
||||
let text = "ab——cd ef";
|
||||
let dash = "ab".len();
|
||||
let natural = layout(db(), &point(text, style(20.0))).glyphs.iter().find(|g| g.byte == dash).map(|g| g.advance).unwrap();
|
||||
let set = |bunri: bool| {
|
||||
let mut t = area(text, style(20.0), frame, Justify::JustifyAll);
|
||||
t.para.justification.letter_spacing = [0.0, 0.0, 100.0];
|
||||
t.para.bunri_kinshi = bunri;
|
||||
let l = layout(db(), &t);
|
||||
l.glyphs.iter().find(|g| g.byte == dash).map(|g| g.advance).unwrap()
|
||||
};
|
||||
assert!(set(false) > natural + 1.0, "off: letter spacing widens the gap after the first dash");
|
||||
assert!((set(true) - natural).abs() < 1e-6, "on: the dashes keep their spacing");
|
||||
// A Desired Letter Spacing adds no space inside the pair on a ragged line either.
|
||||
let desired = |bunri: bool| {
|
||||
let mut t = area(text, style(20.0), frame, Justify::Left);
|
||||
t.para.justification.letter_spacing = [0.0, 50.0, 100.0];
|
||||
t.para.bunri_kinshi = bunri;
|
||||
let l = layout(db(), &t);
|
||||
let adv = |b: usize| l.glyphs.iter().find(|g| g.byte == b).map(|g| g.advance).unwrap();
|
||||
(adv(dash), adv(0))
|
||||
};
|
||||
let (off, _) = desired(false);
|
||||
assert!(off > natural + 1.0, "off: Desired spacing widens the gap after the first dash");
|
||||
let (on, a) = desired(true);
|
||||
assert!((on - natural).abs() < 1e-6, "on: the dashes keep their spacing");
|
||||
assert!(a > layout(db(), &point(text, style(20.0))).glyphs[0].advance + 1.0, "on: other letters still take Desired spacing");
|
||||
// Single Word Justification's Full Justify, right to left: the second dash's cluster (the dash
|
||||
// and a combining mark) is drawn before the first dash, and the mark's gap, which opens next
|
||||
// to the first dash, takes no space. The other gaps of the word still do.
|
||||
let text = "אב——\u{301}גד";
|
||||
let (first, second) = ("אב".len(), "אב—".len());
|
||||
let width = |l: &TextLayout, b: usize| l.glyphs.iter().filter(|g| g.byte == b).map(|g| g.advance).sum::<f64>();
|
||||
let natural = layout(db(), &point(text, style(20.0)));
|
||||
assert!(natural.glyphs.iter().filter(|g| g.byte == second).count() > 1, "the dash and its mark are one cluster");
|
||||
let set = |bunri: bool| {
|
||||
let mut t = area(text, style(20.0), frame, Justify::JustifyAll);
|
||||
t.para.bunri_kinshi = bunri;
|
||||
let l = layout(db(), &t);
|
||||
assert!((l.lines[0].x1 - frame.x1).abs() < 1e-6, "{bunri}: {:?}", l.lines[0]);
|
||||
(width(&l, first), width(&l, second))
|
||||
};
|
||||
let (_, off) = set(false);
|
||||
assert!(off > width(&natural, second) + 1.0, "off: Full Justify widens the gap between the dashes");
|
||||
let (dash, on) = set(true);
|
||||
assert!((on - width(&natural, second)).abs() < 1e-6, "on: the pair keeps its spacing right to left");
|
||||
assert!(dash > width(&natural, first) + 1.0, "on: the gap on the pair's other side still widens");
|
||||
}
|
||||
|
||||
/// No Break (Character panel menu) keeps the marked words on one line, where the same text wraps
|
||||
/// at the space without the mark.
|
||||
#[test]
|
||||
|
||||
@@ -386,7 +386,10 @@ type Colours = Vec<[u8; 3]>;
|
||||
|
||||
/// The colours to trace: those the caller named (white is skipped when it is its own class;
|
||||
/// colours closer than [`SAME_COLOUR`] count as one; at most [`MAX_COLOURS`]) or the ones found,
|
||||
/// the tones of one colour folded into it ([`fold_tones`]); and with them the colours before folding.
|
||||
/// the tones of one colour folded into it ([`fold_tones`]); and with them the image's own colors
|
||||
/// before folding. With named colors, the image's own colors are found the same way as when none are
|
||||
/// named, and as many are looked for as are named, with white, or as the Colors setting gives,
|
||||
/// whichever is more.
|
||||
fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Result<(Colours, Colours), TraceError> {
|
||||
if params.logo_colors.is_empty() {
|
||||
let found = auto_palette(pix, w, h, (params.colors as usize).clamp(2, MAX_COLOURS));
|
||||
@@ -401,7 +404,12 @@ fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Resul
|
||||
palette.push(c);
|
||||
}
|
||||
}
|
||||
Ok((palette.clone(), palette))
|
||||
if palette.is_empty() {
|
||||
return Ok((palette, Vec::new()));
|
||||
}
|
||||
// (One more than named: white takes a slot while the colors are found, and is left out after.)
|
||||
let tones = auto_palette(pix, w, h, (palette.len() + 1).max(params.colors as usize).clamp(2, MAX_COLOURS + 1));
|
||||
Ok((palette, tones))
|
||||
}
|
||||
|
||||
/// Trace a flat-colour logo.
|
||||
@@ -411,8 +419,9 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
|
||||
return Ok(TraceResult::default());
|
||||
}
|
||||
let (mut pix, mut w, mut h, mut factor) = working_copy(img, 1);
|
||||
// (`tones`: the colours as found, each tone of an unevenly lit colour on its own. Whether the
|
||||
// image is flat art is asked of them: a paper's tones are flat colour too.)
|
||||
// (`tones`: the image's own colors as found, each tone of an unevenly lit colour on its own,
|
||||
// also when colors are named. Whether the image is flat art is asked of them: a paper's tones
|
||||
// are flat colour too, and how far the named colors are from the image's does not change the answer.)
|
||||
let (mut palette, tones) = choose_palette(params, &pix, w, h)?;
|
||||
if palette.is_empty() {
|
||||
return Ok(TraceResult::default());
|
||||
|
||||
@@ -372,6 +372,144 @@ fn named_colors_a_few_levels_off_trace_an_image_up_to_1024_px_at_full_size() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A dark ring around a tan disc with a blue square on it, hard edges, on a transparent background.
|
||||
fn hard_badge() -> Raster {
|
||||
Raster::from_fn(160, 160, |x, y| {
|
||||
let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
|
||||
let d = (fx - 80.0).hypot(fy - 80.0);
|
||||
if (60.0..=100.0).contains(&fx) && (60.0..=100.0).contains(&fy) {
|
||||
[34, 124, 250, 255]
|
||||
} else if d <= 60.0 {
|
||||
[200, 170, 120, 255]
|
||||
} else if d <= 68.0 {
|
||||
[4, 4, 3, 255]
|
||||
} else {
|
||||
[0, 0, 0, 0]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_art_is_traced_with_named_colors_far_from_its_own() {
|
||||
// Every named color is 20 levels off the image's in each channel: the image is still flat art,
|
||||
// and each area takes the named color nearest it.
|
||||
let names = ["#181817", "#B49664", "#0E68E6"];
|
||||
let res = trace(&hard_badge(), &TraceParams { logo_colors: names.iter().map(|s| s.to_string()).collect(), ..logo_params() });
|
||||
assert_eq!(res.paths.len(), 3, "{:?}", res.paths.iter().map(|p| p.color).collect::<Vec<_>>());
|
||||
assert_eq!(top_color(&res, Point::new(80.0, 80.0)), Some([14, 104, 230]));
|
||||
assert_eq!(top_color(&res, Point::new(80.0, 30.0)), Some([180, 150, 100]));
|
||||
assert_eq!(top_color(&res, Point::new(80.0, 15.0)), Some([24, 24, 23]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_that_is_not_flat_is_refused_by_its_own_colors_whatever_is_named() {
|
||||
let img = Raster::from_fn(64, 32, |x, y| [(x * 4) as u8, 255 - (x * 4) as u8, (y * 6) as u8, 255]);
|
||||
let refused = |p: &TraceParams| match trace_within(&img, p, 100_000) {
|
||||
Err(TraceError::NotFlat { fraction }) => fraction,
|
||||
other => panic!("expected NotFlat, got {other:?}"),
|
||||
};
|
||||
let own = refused(&logo_params());
|
||||
// Names close to some of the gradient's colors, and names far from all of them.
|
||||
for names in [["#08F710", "#80807F", "#F00C60"], ["#FF00FF", "#00FFFF", "#FFFF00"]] {
|
||||
let named = TraceParams { logo_colors: names.iter().map(|s| s.to_string()).collect(), ..logo_params() };
|
||||
assert_eq!(refused(&named), own, "names {names:?}");
|
||||
assert_eq!(refused(&TraceParams { logo_white: false, ..named }), own, "names {names:?} without white");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hatch_gives_the_same_result_with_close_and_far_named_colors() {
|
||||
// Black lines one pixel wide across, blue lines down, on a cream ground.
|
||||
let img = render(120, 120, 0.5, |x, y| {
|
||||
Some(if y.rem_euclid(8.0) < 1.0 {
|
||||
[12, 12, 12]
|
||||
} else if x.rem_euclid(8.0) < 1.0 {
|
||||
[30, 70, 200]
|
||||
} else {
|
||||
[236, 226, 196]
|
||||
})
|
||||
});
|
||||
let outcome = |names: [&str; 3]| match trace_within(
|
||||
&img,
|
||||
&TraceParams { logo_colors: names.iter().map(|s| s.to_string()).collect(), ..logo_params() },
|
||||
100_000,
|
||||
) {
|
||||
Ok(res) => Ok(res.paths.len()),
|
||||
Err(TraceError::NotFlat { fraction }) => Err(fraction),
|
||||
Err(e) => panic!("{e:?}"),
|
||||
};
|
||||
let close = outcome(["#0C0C0C", "#1E46C8", "#ECE2C4"]);
|
||||
// Every channel 20 levels off.
|
||||
assert_eq!(outcome(["#202020", "#325ADC", "#D8CEB0"]), close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_named_colors_than_the_colors_setting_do_not_make_the_refusal_depend_on_the_names() {
|
||||
let img = Raster::from_fn(64, 32, |x, y| [(x * 4) as u8, 255 - (x * 4) as u8, (y * 6) as u8, 255]);
|
||||
let refused = |p: &TraceParams| match trace_within(&img, p, 100_000) {
|
||||
Err(TraceError::NotFlat { fraction }) => fraction,
|
||||
other => panic!("expected NotFlat, got {other:?}"),
|
||||
};
|
||||
// Six names with Colors at 2: seven of the image's own colors are looked for (six and white), as
|
||||
// with Colors at 7.
|
||||
let own = refused(&TraceParams { colors: 7, ..logo_params() });
|
||||
for names in
|
||||
[["#00FF00", "#40BF3C", "#807F78", "#C03FB4", "#FC03BA", "#649B1E"], ["#FF00FF", "#00FFFF", "#FFFF00", "#000000", "#FF0000", "#0000FF"]]
|
||||
{
|
||||
let named = TraceParams { logo_colors: names.iter().map(|s| s.to_string()).collect(), colors: 2, ..logo_params() };
|
||||
assert_eq!(refused(&named), own, "names {names:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn areas_of_colors_that_are_not_named_are_traced_in_the_named_colors() {
|
||||
// Red, green, blue and yellow bands; only red and blue are named.
|
||||
const BANDS: [[u8; 3]; 4] = [[200, 30, 30], [30, 160, 60], [60, 70, 180], [230, 200, 40]];
|
||||
let img = Raster::from_fn(160, 40, |x, _| {
|
||||
let c = BANDS[(x / 40) as usize];
|
||||
[c[0], c[1], c[2], 255]
|
||||
});
|
||||
let res = trace(&img, &TraceParams { logo_colors: vec!["#C81E1E".into(), "#3C46B4".into()], ..logo_params() });
|
||||
for p in &res.paths {
|
||||
assert!([BANDS[0], BANDS[2], [255, 255, 255]].contains(&p.color), "{:?}", p.color);
|
||||
}
|
||||
assert_eq!(top_color(&res, Point::new(20.0, 20.0)), Some(BANDS[0]));
|
||||
assert_eq!(top_color(&res, Point::new(100.0, 20.0)), Some(BANDS[2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_art_on_white_with_more_named_colors_than_the_colors_setting_is_flat() {
|
||||
// Red, green and blue bands and a white one, the three colors named, with Colors at 2: white
|
||||
// does not take the place of a named color while the image's colors are found.
|
||||
const BANDS: [[u8; 3]; 4] = [[220, 30, 30], [30, 180, 60], [40, 60, 210], [255, 255, 255]];
|
||||
let img = Raster::from_fn(160, 40, |x, _| {
|
||||
let c = BANDS[(x / 40) as usize];
|
||||
[c[0], c[1], c[2], 255]
|
||||
});
|
||||
let names = vec!["#DC1E1E".to_string(), "#1EB43C".to_string(), "#283CD2".to_string()];
|
||||
let res = trace(&img, &TraceParams { logo_colors: names, colors: 2, ..logo_params() });
|
||||
for (i, c) in BANDS.iter().enumerate() {
|
||||
assert_eq!(top_color(&res, Point::new(20.0 + 40.0 * i as f64, 20.0)), Some(*c), "band {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_art_with_more_named_colors_than_the_colors_setting_is_flat() {
|
||||
// Eight flat bands, each color named, with Colors at 2: as many of the image's own colors are
|
||||
// looked for as are named, with white, so the art is still flat.
|
||||
const BANDS: [[u8; 3]; 8] =
|
||||
[[200, 30, 30], [30, 160, 60], [60, 70, 180], [230, 200, 40], [120, 40, 160], [20, 20, 20], [240, 130, 30], [40, 190, 200]];
|
||||
let img = Raster::from_fn(160, 40, |x, _| {
|
||||
let c = BANDS[(x / 20) as usize];
|
||||
[c[0], c[1], c[2], 255]
|
||||
});
|
||||
let names: Vec<String> = BANDS.iter().map(|c| format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])).collect();
|
||||
let res = trace(&img, &TraceParams { logo_colors: names, colors: 2, ..logo_params() });
|
||||
for (i, c) in BANDS.iter().enumerate() {
|
||||
assert_eq!(top_color(&res, Point::new(10.0 + 20.0 * i as f64, 20.0)), Some(*c), "band {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every anchor of every path is finite and inside (a small margin around) the image.
|
||||
fn assert_sane(res: &TraceResult, w: u32, h: u32) {
|
||||
for t in &res.paths {
|
||||
|
||||
@@ -120,8 +120,10 @@ pub struct TraceParams {
|
||||
pub stroke_width: f64,
|
||||
/// Flat Logo mode: the flat colours (`#rrggbb`) to use; empty finds them automatically. White is
|
||||
/// traced on its own and cannot be named unless [`logo_white`](Self::logo_white) is off, colours
|
||||
/// closer than about 12 (RGB distance) count as one, and at most ten are used. (Not `palette`:
|
||||
/// that is Color mode's colour source.)
|
||||
/// closer than about 12 (RGB distance) count as one, and at most ten are used. Whether the image
|
||||
/// is flat art (and not refused) is judged by its own colors, which are found the same way as when
|
||||
/// no colors are named; as many are looked for as are named, with white, or as
|
||||
/// [`colors`](Self::colors) gives, whichever is more. (Not `palette`: that is Color mode's colour source.)
|
||||
pub logo_colors: Vec<String>,
|
||||
/// Flat Logo mode with named `logo_colors`: `true` traces white (every channel 245 or more) as a
|
||||
/// color of its own beside the named ones. `false` traces the named colors only: white areas
|
||||
|
||||
@@ -20,7 +20,7 @@ const FIELD_W: f32 = 96.0;
|
||||
/// Object › Plug-ins › Path Precision Tools › Corner Tool.
|
||||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
app.select_tool(ID);
|
||||
app.run("window.panel", json!({ "panel": super::ID }))
|
||||
crate::panel_docking::reveal_panel(app, super::ID)
|
||||
}
|
||||
|
||||
/// How many corners Apply would edit: the selected paths' direct-selected ones, or all of a path
|
||||
|
||||
@@ -18,7 +18,7 @@ const LABEL_W: f32 = 80.0;
|
||||
/// Object › Plug-ins › Path Precision Tools › Shared Edge Merge Tool.
|
||||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
app.select_tool(ID);
|
||||
app.run("window.panel", json!({ "panel": super::ID }))
|
||||
crate::panel_docking::reveal_panel(app, super::ID)
|
||||
}
|
||||
|
||||
pub(super) fn section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
|
||||
@@ -142,6 +142,24 @@ fn the_corner_tool_item_selects_the_tool_and_opens_its_panel_section() {
|
||||
assert!(!vectorcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| t.id == "cornerTool" || t.id == "slideAnchor"));
|
||||
}
|
||||
|
||||
/// The Corner Tool and Shared Edge Merge Tool items leave a floating Anchor Inspector showing
|
||||
/// instead of closing it, and in Presentation Mode they choose the tool without failing.
|
||||
#[test]
|
||||
fn the_tool_items_keep_a_floating_anchor_inspector_showing() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.float", json!({ "panel": ID })).unwrap();
|
||||
assert!(crate::panel_docking::visible(&app.ui, ID));
|
||||
for command in [super::corner::COMMAND, super::shared_edge::COMMAND] {
|
||||
app.run(command, json!({})).unwrap();
|
||||
assert!(crate::panel_docking::visible(&app.ui, ID), "{command} leaves the panel showing");
|
||||
}
|
||||
app.ui.screen_mode = 3;
|
||||
app.run(super::corner::COMMAND, json!({})).unwrap();
|
||||
assert_eq!(app.session.tool_id(), "cornerTool");
|
||||
app.ui.screen_mode = 0;
|
||||
assert!(crate::panel_docking::visible(&app.ui, ID), "the panel shows when Presentation Mode ends");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shared_edge_item_selects_the_tool_and_opens_its_panel_section() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -89,6 +89,7 @@ impl VectorcraftApp {
|
||||
selection_bounds: None,
|
||||
content_bounds: None,
|
||||
plugin_keys: Default::default(),
|
||||
placed_keys: Default::default(),
|
||||
cursors: Default::default(),
|
||||
view_moved: None,
|
||||
hover_cursor: None,
|
||||
|
||||
@@ -61,6 +61,8 @@ pub struct CanvasCache {
|
||||
/// [`CacheKey::plugins`] by document uid, with the plug-in revision
|
||||
/// ([`vectorcraft_render::effects::plugin_revision`]) it was last checked at.
|
||||
pub plugin_keys: std::collections::HashMap<u64, (u64, u64)>,
|
||||
/// [`CacheKey::placed`] by document uid ([`canvas::placed_key`]).
|
||||
pub placed_keys: std::collections::HashMap<u64, PlacedKey>,
|
||||
/// The tools' cursors as OS cursor bitmaps.
|
||||
pub cursors: cursors::Images,
|
||||
/// The view the canvas last drew and when it last changed (egui's input time): while it moves,
|
||||
@@ -82,6 +84,20 @@ pub struct CanvasCache {
|
||||
pub windows: crate::doc_windows::Windows,
|
||||
}
|
||||
|
||||
/// [`CanvasCache::placed_keys`]: a document's [`CacheKey::placed`] and the placed-document bitmap
|
||||
/// generations ([`vectorcraft_render::placed_document::generation`]) it was checked at.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PlacedKey {
|
||||
/// The last generation the document was checked at.
|
||||
pub checked: u64,
|
||||
/// The document's [`CacheKey::placed`]: the generation of its first check, or of the last check
|
||||
/// that found a newly made bitmap the document may show.
|
||||
pub key: u64,
|
||||
/// The value before it last changed, and the last generation that value was checked at: no
|
||||
/// bitmap of a placed document the document shows was made between the two.
|
||||
pub before: (u64, u64),
|
||||
}
|
||||
|
||||
/// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision).
|
||||
pub type SliceCache = ((u64, u64), std::sync::Arc<Vec<vectorcraft_doc::SliceArea>>);
|
||||
|
||||
@@ -120,7 +136,10 @@ pub struct CacheKey {
|
||||
pub highlight_alternate_glyphs: bool,
|
||||
/// Type › Highlight Substituted Fonts.
|
||||
pub highlight_substituted_fonts: bool,
|
||||
/// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since.
|
||||
/// A [`vectorcraft_render::placed_document::generation`] that changes when the frame may show a
|
||||
/// newly made bitmap of a placed document ([`canvas::placed_key`]). Bitmaps of files that only
|
||||
/// other documents hold leave it as it is, unless more bitmaps were made since the last check
|
||||
/// than are recorded and the document shows a placed document.
|
||||
pub placed: u64,
|
||||
/// The plug-in revision ([`vectorcraft_render::effects::plugin_revision`]) of the last plug-in
|
||||
/// install or removal, or of the last time deferred plug-in effect stages gave results, after
|
||||
|
||||
@@ -327,9 +327,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// their pixels as they rasterize, hard-edged.
|
||||
let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps;
|
||||
let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st);
|
||||
let placed = placed_key(&mut app.canvas.placed_keys, &app.session, st);
|
||||
// The frame renders the view at its zoom in the interface's points, as `xf` maps it.
|
||||
let drawn = v.on_screen(app.canvas_ui_zoom);
|
||||
let key = cache_key(app, ui, st, &t, &drawn, (w, h, ppp), (pixel, smooth_images, plugins));
|
||||
let key = cache_key(app, ui, st, &t, &drawn, (w, h, ppp), (pixel, smooth_images, plugins, placed));
|
||||
// Placed documents' bitmaps are being made: draw again when they are ready.
|
||||
if vectorcraft_render::placed_document::busy() {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(30));
|
||||
@@ -579,7 +580,7 @@ fn cache_key(
|
||||
t: &Tokens,
|
||||
v: &View,
|
||||
(w, h, ppp): (u32, u32, f32),
|
||||
(pixel, smooth_images, plugins): (Option<[i64; 4]>, bool, u64),
|
||||
(pixel, smooth_images, plugins, placed): (Option<[i64; 4]>, bool, u64, u64),
|
||||
) -> CacheKey {
|
||||
// Paths made into guides are drawn as the ruler guides are (Preferences › Guides & Grid).
|
||||
let guide_look = LineLook::guides(&app.session.prefs, t);
|
||||
@@ -603,7 +604,7 @@ fn cache_key(
|
||||
anti_alias: app.session.prefs.anti_aliased_artwork,
|
||||
highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs,
|
||||
highlight_substituted_fonts: app.session.prefs.highlight_substituted_fonts,
|
||||
placed: vectorcraft_render::placed_document::generation(),
|
||||
placed,
|
||||
plugins,
|
||||
pixel,
|
||||
smooth_images,
|
||||
@@ -637,6 +638,83 @@ fn plugin_key(
|
||||
}
|
||||
}
|
||||
|
||||
/// [`CacheKey::placed`] of document `st`, one of the documents open in `session`. `keys` holds each
|
||||
/// document's value and the placed-document bitmap generations it was checked at
|
||||
/// ([`vectorcraft_render::placed_document::generation`]).
|
||||
fn placed_key(
|
||||
keys: &mut std::collections::HashMap<u64, crate::PlacedKey>,
|
||||
session: &vectorcraft_engine::Session,
|
||||
st: &vectorcraft_engine::DocState,
|
||||
) -> u64 {
|
||||
placed_key_at(keys, session, st, vectorcraft_render::placed_document::generation(), vectorcraft_render::placed_document::made_between)
|
||||
}
|
||||
|
||||
/// [`placed_key`] at generation `now`, with `made` giving the files whose bitmaps were made between
|
||||
/// two generations ([`vectorcraft_render::placed_document::made_between`]). The value becomes `now`
|
||||
/// when `st` shows a placed document of a file whose bitmap was made since the last check, or when
|
||||
/// `made` no longer knows which files they were and `st` shows a placed document of a file in its
|
||||
/// images, as [`vectorcraft_render::placed_area`] decides (every file shows while a pattern is
|
||||
/// edited). Otherwise it stays: a bitmap made for another document, or for a file `st` keeps only
|
||||
/// for Undo, leaves this document's frame as it is. A document checked for the first time takes
|
||||
/// `now`.
|
||||
fn placed_key_at(
|
||||
keys: &mut std::collections::HashMap<u64, crate::PlacedKey>,
|
||||
session: &vectorcraft_engine::Session,
|
||||
st: &vectorcraft_engine::DocState,
|
||||
now: u64,
|
||||
made: impl FnOnce(u64, u64) -> Option<std::collections::HashSet<String>>,
|
||||
) -> u64 {
|
||||
let entry = match keys.get(&st.uid).copied() {
|
||||
Some(p) if p.checked == now => return p.key,
|
||||
None => crate::PlacedKey { checked: now, key: now, before: (now, now) },
|
||||
Some(p) => {
|
||||
let images = &st.doc.images;
|
||||
// The files made that `st` holds: a placed document of one of them may show.
|
||||
let files: std::collections::HashSet<String> = if images.is_empty() {
|
||||
Default::default()
|
||||
} else {
|
||||
match made(p.checked, now) {
|
||||
Some(files) => files.into_iter().filter(|f| images.contains_key(f)).collect(),
|
||||
None => images.keys().cloned().collect(),
|
||||
}
|
||||
};
|
||||
let stays = matches!(vectorcraft_render::placed_area(&st.doc, &files), vectorcraft_render::Changed::Nothing);
|
||||
if stays { crate::PlacedKey { checked: now, ..p } } else { crate::PlacedKey { checked: now, key: now, before: (p.key, p.checked) } }
|
||||
}
|
||||
};
|
||||
keys.retain(|uid, _| session.documents().iter().any(|d| d.uid == *uid));
|
||||
keys.insert(st.uid, entry);
|
||||
entry.key
|
||||
}
|
||||
|
||||
/// The generation after which the bitmaps made decide what a frame shown at [`CacheKey::placed`]
|
||||
/// value `shown` draws again at value `key`, with `entry` the document's [`crate::PlacedKey`]. When
|
||||
/// the value changed once since `shown`, it is the last generation `shown` was checked at: no
|
||||
/// bitmap of the document's placed documents was made between `shown` and that generation.
|
||||
/// Otherwise it is `shown`.
|
||||
fn placed_since(entry: Option<&crate::PlacedKey>, shown: u64, key: u64) -> u64 {
|
||||
entry.filter(|p| p.key == key && p.before.0 == shown).map_or(shown, |p| p.before.1)
|
||||
}
|
||||
|
||||
/// What placed documents' bitmaps change in `doc` between a frame shown at [`CacheKey::placed`]
|
||||
/// value `shown` and the frame at value `key`: where `doc` shows the files whose bitmaps were made
|
||||
/// after [`placed_since`], with `entry` the document's [`crate::PlacedKey`] and `made` giving the
|
||||
/// files whose bitmaps were made between two generations
|
||||
/// ([`vectorcraft_render::placed_document::made_between`]). None when those files are no longer
|
||||
/// known.
|
||||
fn placed_changes(
|
||||
entry: Option<&crate::PlacedKey>,
|
||||
shown: u64,
|
||||
key: u64,
|
||||
doc: &vectorcraft_doc::Document,
|
||||
made: impl FnOnce(u64, u64) -> Option<std::collections::HashSet<String>>,
|
||||
) -> Option<vectorcraft_render::Changed> {
|
||||
if shown == key {
|
||||
return Some(vectorcraft_render::Changed::Nothing);
|
||||
}
|
||||
Some(vectorcraft_render::placed_area(doc, &made(placed_since(entry, shown, key), key)?))
|
||||
}
|
||||
|
||||
/// Once the first view of a document has been drawn, the status bar says how many plug-in effect
|
||||
/// stages it left deferred, if any.
|
||||
fn report_deferred(app: &mut VectorcraftApp, effects: &vectorcraft_plugins::deferred::Deferrals) {
|
||||
|
||||
@@ -12,8 +12,8 @@ use super::outline::{c32, node_outline, stroke_path, too_many_anchors};
|
||||
use super::view::fit_zoom;
|
||||
use super::{
|
||||
Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, cache_key,
|
||||
dim_on_canvas, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, plugin_key, pos2, rulers, shows_artboards,
|
||||
textured_quad, vec2, view_rects,
|
||||
dim_on_canvas, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, placed_key, plugin_key, pos2, rulers,
|
||||
shows_artboards, textured_quad, vec2, view_rects,
|
||||
};
|
||||
use crate::doc_windows::Win;
|
||||
use crate::thumb_worker::ThumbWorker;
|
||||
@@ -305,8 +305,9 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect)
|
||||
let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten();
|
||||
let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps;
|
||||
let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st);
|
||||
let placed = placed_key(&mut app.canvas.placed_keys, &app.session, st);
|
||||
let drawn = v.on_screen(ui_zoom);
|
||||
let mut key = cache_key(app, ui, st, &t, &drawn, (pw, ph, ppp), (pixel, smooth_images, plugins));
|
||||
let mut key = cache_key(app, ui, st, &t, &drawn, (pw, ph, ppp), (pixel, smooth_images, plugins, placed));
|
||||
// The current document's drag and Image Trace hold are not this document's, and this view has
|
||||
// its own Outline or Preview.
|
||||
(key.dragging, key.trace_source, key.outline) = (false, None, v.outline);
|
||||
|
||||
@@ -323,6 +323,22 @@ fn other_panes_show_pixel_preview() {
|
||||
assert!(x1 > x0 && y1 > y0);
|
||||
}
|
||||
|
||||
/// A pane's render key takes its document's [`CacheKey::placed`] value ([`placed_key`]), which a
|
||||
/// bitmap made for a test running beside this one does not move: the document holds no images.
|
||||
#[test]
|
||||
fn a_panes_key_takes_its_documents_placed_value() {
|
||||
let mut app = app_with(2);
|
||||
let ctx = egui::Context::default();
|
||||
app.run("window.arrange.tile", json!({})).unwrap();
|
||||
let uid = app.session.documents()[0].uid;
|
||||
let generation = vectorcraft_render::placed_document::generation();
|
||||
let marked = generation.wrapping_add(1000);
|
||||
app.canvas.placed_keys.insert(uid, crate::PlacedKey { checked: generation, key: marked, before: (marked, generation) });
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let key = app.canvas.panes.shown.get(&(uid, 1)).map(|(k, _)| k.placed);
|
||||
assert_eq!(key, Some(marked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resized_window_fits_the_tiled_documents_again_unless_one_was_zoomed() {
|
||||
let mut app = app_with(3);
|
||||
|
||||
@@ -57,11 +57,8 @@ pub(super) fn changed_pixels(
|
||||
return None;
|
||||
}
|
||||
// The placed documents' files whose bitmaps were made since the frame shown.
|
||||
let placed = if shown_key.placed == key.placed {
|
||||
vectorcraft_render::Changed::Nothing
|
||||
} else {
|
||||
vectorcraft_render::placed_area(doc, &vectorcraft_render::placed_document::made_between(shown_key.placed, key.placed)?)
|
||||
};
|
||||
let entry = app.session.documents().iter().find(|d| d.uid as usize == key.doc).and_then(|d| app.canvas.placed_keys.get(&d.uid));
|
||||
let placed = super::placed_changes(entry, shown_key.placed, key.placed, doc, vectorcraft_render::placed_document::made_between)?;
|
||||
match app.canvas.renderer.changed(shown, doc, opts).and(placed) {
|
||||
vectorcraft_render::Changed::Nothing => Some(None),
|
||||
vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) {
|
||||
|
||||
@@ -2856,13 +2856,10 @@ fn cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes() {
|
||||
"{old_rot}° -> {new_rot}°, DPI {old_ppp} -> {new_ppp}, pixel={pixel}: {vertex:?}"
|
||||
);
|
||||
}
|
||||
// Deliver the actual replacement and verify that it replaces the cached key. The frame
|
||||
// reports a slow render: when a parallel test makes a placed document's bitmap, the key's
|
||||
// process-wide `placed` generation moves, and the next frame goes to the controlled worker
|
||||
// instead of rendering over the delivered frame on this thread.
|
||||
// Deliver the actual replacement and verify that it replaces the cached key.
|
||||
let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||||
let expected = job.key.clone();
|
||||
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 50.0, moved: 0, reused: false }).unwrap();
|
||||
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0, reused: false }).unwrap();
|
||||
frame(&mut app, size, new_ppp);
|
||||
assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived");
|
||||
}
|
||||
@@ -2942,21 +2939,6 @@ fn a_turned_view_draws_the_last_frame_turned_until_its_own_arrives() {
|
||||
/// fill is dropped.
|
||||
#[test]
|
||||
fn a_pan_fills_the_area_the_last_frame_leaves_until_the_worker_frame_arrives() {
|
||||
// The canvas key holds the global placed-document bitmap clock, which tests on other threads
|
||||
// move: a frame whose key changed by more than the view drops the fill. An attempt that fails
|
||||
// while the clock moved runs again; one that fails while it stood still is a real failure.
|
||||
for _ in 0..20 {
|
||||
let clock = vectorcraft_render::placed_document::generation();
|
||||
match std::panic::catch_unwind(pan_fills_the_area_the_last_frame_leaves) {
|
||||
Ok(()) => return,
|
||||
Err(_) if vectorcraft_render::placed_document::generation() != clock => {}
|
||||
Err(e) => std::panic::resume_unwind(e),
|
||||
}
|
||||
}
|
||||
pan_fills_the_area_the_last_frame_leaves();
|
||||
}
|
||||
|
||||
fn pan_fills_the_area_the_last_frame_leaves() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
@@ -3185,6 +3167,200 @@ fn the_canvas_waits_only_while_the_view_of_a_slow_frame_changes() {
|
||||
assert_eq!(still_wait(&mut still, Some(&shown), &shown, true, 15.0), None, "the frame shown");
|
||||
}
|
||||
|
||||
/// A bitmap made in the background for a file that only another document holds leaves this
|
||||
/// document's canvas key as it is. Without a render worker (the web build's setting), a pan of a
|
||||
/// heavy canvas keeps drawing the frame shown until the view is still. A light canvas renders the
|
||||
/// pan at once, and its key differs from the frame shown only in the view: the pan fill and the
|
||||
/// worker's pixel copy ask for that.
|
||||
#[test]
|
||||
fn a_bitmap_made_for_another_documents_file_leaves_a_pan_waiting() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||||
app.canvas.worker_started = true; // no worker, as in the web build
|
||||
app.canvas.waits_for_still = true;
|
||||
// Another window's document places a file, which its canvas asks a bitmap of.
|
||||
let mut other = VectorcraftApp::new(Session::new(), Default::default());
|
||||
other.canvas.worker_started = true; // render on this thread
|
||||
let mut src = Session::new();
|
||||
src.execute("file.new", &json!({"width": 70, "height": 40})).unwrap();
|
||||
src.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 70, "height": 40})).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-placed-pan-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("badge.vectorcraft");
|
||||
std::fs::write(&path, vectorcraft_format::save_file(&src.active().unwrap().doc)).unwrap();
|
||||
other.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
other.session.execute("file.place", &json!({"path": path.to_string_lossy(), "at": [100, 100]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut VectorcraftApp| {
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
frame(&mut app); // the first frame fits the view to the artboard
|
||||
frame(&mut app);
|
||||
app.canvas.last_ms = 1.0;
|
||||
app.view_mut().unwrap().center.x += 10.0;
|
||||
frame(&mut app);
|
||||
let shown = app.canvas.key.clone().unwrap();
|
||||
// The other canvas asks for the bitmap, and it is made: the generation changes before the
|
||||
// bitmap stops being pending.
|
||||
let generation = vectorcraft_render::placed_document::generation();
|
||||
shapes(&mut other, &egui::Context::default());
|
||||
while vectorcraft_render::placed_document::busy() {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert_ne!(vectorcraft_render::placed_document::generation(), generation, "a bitmap was made");
|
||||
app.canvas.last_ms = 50.0;
|
||||
app.view_mut().unwrap().center.x += 10.0;
|
||||
frame(&mut app);
|
||||
assert_eq!(app.canvas.key.as_ref(), Some(&shown), "a heavy canvas is not rendered while the view moves");
|
||||
app.canvas.last_ms = 1.0;
|
||||
app.view_mut().unwrap().center.x += 10.0;
|
||||
frame(&mut app);
|
||||
let next = app.canvas.key.clone().unwrap();
|
||||
assert_eq!(next.cx, app.view().unwrap().center.x, "a light canvas renders at once");
|
||||
assert!(super::cursor::only_view_differs(&shown, &next), "only the view differs: {shown:?} {next:?}");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A document's [`CacheKey::placed`] moves to the current placed-document bitmap generation only
|
||||
/// when a bitmap of a file it shows in a placed document was made, or when the files made are no
|
||||
/// longer known and it shows a placed document. A bitmap of another document's file, or of a file
|
||||
/// it keeps only for Undo, leaves it, so a pan of a heavy
|
||||
/// document keeps waiting for the view to stop. Each change keeps the value before it and the
|
||||
/// last generation that value was checked at. A closed document's value is dropped.
|
||||
#[test]
|
||||
fn a_documents_placed_key_moves_only_for_bitmaps_of_its_own_files() {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
let plain = session.active().unwrap().uid;
|
||||
let mut src = Session::new();
|
||||
src.execute("file.new", &json!({"width": 60, "height": 30})).unwrap();
|
||||
src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 60, "height": 30})).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-placed-key-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("card.vectorcraft");
|
||||
std::fs::write(&path, vectorcraft_format::save_file(&src.active().unwrap().doc)).unwrap();
|
||||
session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let r = session.execute("file.place", &json!({"path": path.to_string_lossy(), "at": [60, 50]})).unwrap();
|
||||
let st = session.active().unwrap();
|
||||
let placing = st.uid;
|
||||
let file = match &st.doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap().kind {
|
||||
vectorcraft_doc::NodeKind::PlacedDocument(p) => p.key.clone(),
|
||||
other => panic!("a placed document: {other:?}"),
|
||||
};
|
||||
type Made<'a> = &'a dyn Fn(u64, u64) -> Option<std::collections::HashSet<String>>;
|
||||
fn key(keys: &mut std::collections::HashMap<u64, crate::PlacedKey>, session: &Session, uid: u64, now: u64, made: Made<'_>) -> u64 {
|
||||
let st = session.documents().iter().find(|d| d.uid == uid).unwrap();
|
||||
placed_key_at(keys, session, st, now, made)
|
||||
}
|
||||
let files = |names: &[&str]| Some(names.iter().map(|n| n.to_string()).collect::<std::collections::HashSet<_>>());
|
||||
let unasked = |_: u64, _: u64| -> Option<std::collections::HashSet<String>> { panic!("no bitmaps to look up") };
|
||||
let mut keys = std::collections::HashMap::new();
|
||||
assert_eq!(key(&mut keys, &session, placing, 5, &unasked), 5, "checked for the first time");
|
||||
assert_eq!(key(&mut keys, &session, placing, 5, &unasked), 5, "no bitmap made since");
|
||||
let other = |from: u64, to: u64| {
|
||||
assert_eq!((from, to), (5, 6), "the bitmaps made since the last check");
|
||||
files(&["another document's file"])
|
||||
};
|
||||
assert_eq!(key(&mut keys, &session, placing, 6, &other), 5, "another document's file");
|
||||
assert_eq!(key(&mut keys, &session, placing, 7, &|_, _| files(&["another document's file", file.as_str()])), 7, "its own file");
|
||||
assert_eq!(keys[&placing], crate::PlacedKey { checked: 7, key: 7, before: (5, 6) });
|
||||
assert_eq!(key(&mut keys, &session, placing, 8, &|_, _| None), 8, "files no longer known");
|
||||
assert_eq!(keys[&placing].before, (7, 7));
|
||||
assert_eq!(key(&mut keys, &session, plain, 8, &unasked), 8, "the other document, checked for the first time");
|
||||
assert_eq!(key(&mut keys, &session, plain, 9, &|_, _| None), 8, "no images: files no longer known");
|
||||
assert_eq!(key(&mut keys, &session, plain, 10, &|_, _| files(&[file.as_str()])), 8, "no images: a file another document holds");
|
||||
// The same document holding an image and showing no placed document.
|
||||
let mut raster = session.documents().iter().find(|d| d.uid == plain).unwrap().clone();
|
||||
let blob = vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Default::default(), proxy: None };
|
||||
std::sync::Arc::make_mut(&mut raster.doc).images.insert("an image".into(), blob);
|
||||
assert_eq!(placed_key_at(&mut keys, &session, &raster, 11, |_, _| None), 8, "an image and no placed document: files no longer known");
|
||||
assert_eq!(keys.len(), 2);
|
||||
let index = session.documents().iter().position(|d| d.uid == plain).unwrap();
|
||||
session.execute("file.close", &json!({"index": index})).unwrap();
|
||||
assert_eq!(key(&mut keys, &session, placing, 12, &|_, _| files(&[])), 8, "no bitmaps of its files");
|
||||
assert_eq!(keys.keys().copied().collect::<Vec<_>>(), [placing], "the closed document's value is dropped");
|
||||
// The placed document deleted: its file stays in the images for Undo, and no longer shows.
|
||||
assert_eq!(session.active().unwrap().uid, placing);
|
||||
session.execute("edit.clear", &json!({"ids": [r["ids"][0]]})).unwrap();
|
||||
assert!(session.active().unwrap().doc.images.contains_key(&file), "kept for Undo");
|
||||
assert_eq!(key(&mut keys, &session, placing, 13, &|_, _| files(&[file.as_str()])), 8, "a file kept only for Undo");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The canvas key takes the document's value from [`placed_key`], which a bitmap made for a test
|
||||
/// running beside this one does not move: this document holds no images.
|
||||
#[test]
|
||||
fn the_canvas_key_takes_the_documents_placed_value() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||||
app.canvas.worker_started = true; // render on this thread
|
||||
let uid = app.session.active().unwrap().uid;
|
||||
let generation = vectorcraft_render::placed_document::generation();
|
||||
let marked = generation.wrapping_add(1000);
|
||||
app.canvas.placed_keys.insert(uid, crate::PlacedKey { checked: generation, key: marked, before: (marked, generation) });
|
||||
shapes(&mut app, &egui::Context::default());
|
||||
assert_eq!(app.canvas.key.as_ref().map(|k| k.placed), Some(marked));
|
||||
}
|
||||
|
||||
/// A frame shown at one [`CacheKey::placed`] value and drawn again at the next looks at the
|
||||
/// bitmaps made since the last check before the change: those made earlier were of other
|
||||
/// documents' files, and past 256 bitmaps they are no longer known ([`placed_since`]).
|
||||
#[test]
|
||||
fn a_frame_drawn_again_after_one_change_of_its_placed_value_looks_at_the_bitmaps_since_the_last_check() {
|
||||
let entry = crate::PlacedKey { checked: 400, key: 400, before: (5, 399) };
|
||||
assert_eq!(placed_since(Some(&entry), 5, 400), 399, "the value changed once since the frame shown");
|
||||
assert_eq!(placed_since(Some(&entry), 3, 400), 3, "it changed more than once");
|
||||
assert_eq!(placed_since(Some(&entry), 5, 401), 5, "a value the document no longer has");
|
||||
assert_eq!(placed_since(None, 5, 400), 5, "a document not checked");
|
||||
}
|
||||
|
||||
/// A frame drawn again at a new [`CacheKey::placed`] value draws again where the document shows
|
||||
/// the files whose bitmaps were made after [`placed_since`]. After one change of the value, the
|
||||
/// bitmaps made before the last check of the old value are not looked up: past 256 bitmaps they
|
||||
/// are no longer known, and the whole frame would render. At the same value nothing is looked up.
|
||||
#[test]
|
||||
fn a_frame_drawn_again_at_a_new_placed_value_draws_where_the_bitmaps_since_the_last_check_show() {
|
||||
let mut session = Session::new();
|
||||
let mut src = Session::new();
|
||||
src.execute("file.new", &json!({"width": 60, "height": 30})).unwrap();
|
||||
src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 60, "height": 30})).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-placed-changes-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("card.vectorcraft");
|
||||
std::fs::write(&path, vectorcraft_format::save_file(&src.active().unwrap().doc)).unwrap();
|
||||
session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let r = session.execute("file.place", &json!({"path": path.to_string_lossy(), "at": [60, 50]})).unwrap();
|
||||
session.execute("shape.rectangle", &json!({"x": 250, "y": 200, "width": 40, "height": 30})).unwrap();
|
||||
let doc = &session.active().unwrap().doc;
|
||||
let placed = doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap();
|
||||
let file = match &placed.kind {
|
||||
vectorcraft_doc::NodeKind::PlacedDocument(p) => p.key.clone(),
|
||||
other => panic!("a placed document: {other:?}"),
|
||||
};
|
||||
let bounds = placed.geometric_bounds().unwrap();
|
||||
let entry = crate::PlacedKey { checked: 400, key: 400, before: (5, 399) };
|
||||
// Only the bitmaps made after generation 399 are known: one of the placed file.
|
||||
let made = |from: u64, to: u64| (from >= 399 && to == 400).then(|| std::collections::HashSet::from([file.clone()]));
|
||||
let unasked = |_: u64, _: u64| -> Option<std::collections::HashSet<String>> { panic!("no bitmaps to look up") };
|
||||
assert_eq!(placed_changes(Some(&entry), 400, 400, doc, unasked), Some(vectorcraft_render::Changed::Nothing), "the same value");
|
||||
match placed_changes(Some(&entry), 5, 400, doc, made) {
|
||||
Some(vectorcraft_render::Changed::Area(a)) => {
|
||||
assert!(a.x0 <= bounds.x0 && a.y0 <= bounds.y0 && a.x1 >= bounds.x1 && a.y1 >= bounds.y1, "{a:?} holds {bounds:?}");
|
||||
assert!(a.x1 < 250.0, "{a:?}: not the rectangle");
|
||||
}
|
||||
other => panic!("the placed document's part: {other:?}"),
|
||||
}
|
||||
assert_eq!(placed_changes(Some(&entry), 3, 400, doc, made), None, "the value changed more than once: no longer known");
|
||||
let none = |_: u64, _: u64| Some(std::collections::HashSet::new());
|
||||
assert_eq!(placed_changes(Some(&entry), 5, 400, doc, none), Some(vectorcraft_render::Changed::Nothing), "no bitmaps since");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (#394): off, Pixel
|
||||
/// Preview draws images with each pixel taking its nearest image pixel; on, smoothly, as the
|
||||
/// canvas always does out of Pixel Preview.
|
||||
@@ -11592,31 +11768,11 @@ fn a_pan_over_art_the_frame_size_decides_renders_whole() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `test`, whose canvas key holds the global placed-document bitmap clock, which tests on
|
||||
/// other threads move: a frame whose key changed by more than the view renders whole. An attempt
|
||||
/// that fails while the clock moved runs again; one that fails while it stood still is a real
|
||||
/// failure.
|
||||
fn while_the_placed_clock_stands_still(test: fn()) {
|
||||
for _ in 0..20 {
|
||||
let clock = vectorcraft_render::placed_document::generation();
|
||||
match std::panic::catch_unwind(test) {
|
||||
Ok(()) => return,
|
||||
Err(_) if vectorcraft_render::placed_document::generation() != clock => {}
|
||||
Err(e) => std::panic::resume_unwind(e),
|
||||
}
|
||||
}
|
||||
test();
|
||||
}
|
||||
|
||||
/// The canvas asks the worker for a frame that may copy pixels when the view moves, keeps the whole
|
||||
/// frame's time, and renders the frame whole once the view has been still for STILL_SECS, not
|
||||
/// before and only once.
|
||||
#[test]
|
||||
fn a_frame_with_copied_pixels_renders_whole_once_the_view_stops() {
|
||||
while_the_placed_clock_stands_still(frame_with_copied_pixels_renders_whole_once_the_view_stops);
|
||||
}
|
||||
|
||||
fn frame_with_copied_pixels_renders_whole_once_the_view_stops() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.canvas.worker_started = true;
|
||||
@@ -11663,10 +11819,6 @@ fn frame_with_copied_pixels_renders_whole_once_the_view_stops() {
|
||||
/// first step of the next pan copies from it. The jobs are the ones the canvas asks for.
|
||||
#[test]
|
||||
fn a_pan_after_the_settling_frame_copies() {
|
||||
while_the_placed_clock_stands_still(pan_after_the_settling_frame_copies);
|
||||
}
|
||||
|
||||
fn pan_after_the_settling_frame_copies() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.canvas.worker_started = true;
|
||||
@@ -11778,9 +11930,7 @@ fn a_documents_first_frame_renders_on_the_worker() {
|
||||
let job = jobs.try_recv().expect("the first frame is queued on the worker");
|
||||
assert!(app.canvas.texture.is_none(), "not rendered on the UI thread");
|
||||
frame(&mut app);
|
||||
// A placed document of a test running beside this one moves the process-wide generation in
|
||||
// the key: that makes a new frame, not this one again.
|
||||
assert!(jobs.try_iter().all(|j| j.key.placed != job.key.placed), "not queued again while it renders");
|
||||
assert!(jobs.try_recv().is_err(), "not queued again while it renders");
|
||||
assert!(app.canvas.texture.is_none());
|
||||
|
||||
done_tx.send(finished(&job)).unwrap();
|
||||
|
||||
@@ -1073,8 +1073,8 @@ fn menu_dialogs_open_after_a_merge_on_another_thread() {
|
||||
}
|
||||
|
||||
/// The Separations panel's checkboxes and a preference set while a Shape Builder merge is
|
||||
/// computed on another thread give the documents new revisions: the merge is applied first,
|
||||
/// and the document keeps it.
|
||||
/// computed on another thread apply the merge first, and the document keeps it. The checkboxes
|
||||
/// leave the view off, so they apply it by command id without giving the documents new revisions.
|
||||
#[test]
|
||||
fn panels_that_redraw_apply_a_merge_on_another_thread_first() {
|
||||
let cases = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Paragraph Style Options fields shared by editing and creating a paragraph style: General, Basic
|
||||
//! Paragraph Formats (alignment, indents and spacing), Tabs (the style's tab stops), Composition
|
||||
//! (composer and Roman Hanging Punctuation), Hyphenation, Justification and East Asian Composition
|
||||
//! Settings (Korean Line Break Anywhere).
|
||||
//! Settings (Bunri-Kinshi and Korean Line Break Anywhere).
|
||||
//!
|
||||
//! Each field is unset (empty, or a checkbox in its unset state) or set, and an unset field leaves
|
||||
//! its attribute out of the style. Distances are shown and typed in the General unit and kept in
|
||||
@@ -105,6 +105,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
"glyph_max",
|
||||
"auto_leading",
|
||||
"single_word",
|
||||
"bunri_kinshi",
|
||||
"korean_line_break_anywhere",
|
||||
"tabs",
|
||||
"section",
|
||||
@@ -234,7 +235,7 @@ pub(crate) fn fields_from_attrs(attrs: &Map<String, Value>, unit: Unit) -> Map<S
|
||||
fields.insert(key.into(), distance_field(attrs.get(key), unit));
|
||||
}
|
||||
fields.insert("composer".into(), attrs.get("composer").filter(|v| v.is_string()).cloned().unwrap_or(Value::Null));
|
||||
for key in ["roman_hanging", "hyphenate", "korean_line_break_anywhere"] {
|
||||
for key in ["roman_hanging", "hyphenate", "bunri_kinshi", "korean_line_break_anywhere"] {
|
||||
fields.insert(key.into(), attrs.get(key).filter(|v| v.is_boolean()).cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
let hyphenation = attrs.get("hyphenation");
|
||||
@@ -378,8 +379,10 @@ pub(crate) fn edited_attrs(d: &Dialog) -> Result<Map<String, Value>, String> {
|
||||
let value = if same { None } else { tab_stops(d, &original, unit)? };
|
||||
keep_or(&mut attrs, "tabs", same, value);
|
||||
|
||||
let same = unchanged("korean_line_break_anywhere", "__resetEastAsian");
|
||||
keep_or(&mut attrs, "korean_line_break_anywhere", same, switch("korean_line_break_anywhere"));
|
||||
for key in ["bunri_kinshi", "korean_line_break_anywhere"] {
|
||||
let same = unchanged(key, "__resetEastAsian");
|
||||
keep_or(&mut attrs, key, same, switch(key));
|
||||
}
|
||||
Ok(attrs)
|
||||
}
|
||||
|
||||
@@ -559,9 +562,11 @@ fn reset_tabs(d: &mut Dialog) {
|
||||
d.fields.insert("__tabSel".into(), json!(0));
|
||||
}
|
||||
|
||||
/// Reset Panel in East Asian Composition Settings: Korean Line Break Anywhere becomes unset.
|
||||
/// Reset Panel in East Asian Composition Settings: Bunri-Kinshi and Korean Line Break Anywhere
|
||||
/// become unset.
|
||||
fn reset_east_asian(d: &mut Dialog) {
|
||||
d.fields.insert("__resetEastAsian".into(), json!(true));
|
||||
d.fields.insert("bunri_kinshi".into(), Value::Null);
|
||||
d.fields.insert("korean_line_break_anywhere".into(), Value::Null);
|
||||
}
|
||||
|
||||
@@ -905,8 +910,12 @@ pub(crate) fn justification_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
});
|
||||
}
|
||||
|
||||
/// East Asian Composition Settings: Korean Composition Settings › Korean Line Break Anywhere.
|
||||
/// East Asian Composition Settings: Bunri-Kinshi, and Korean Composition Settings › Korean Line
|
||||
/// Break Anywhere.
|
||||
pub(crate) fn east_asian_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
egui::Grid::new("paragraph-style-bunri-kinshi").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| {
|
||||
switch(ui, d, "bunri_kinshi", tl!("Bunri-Kinshi"), true);
|
||||
});
|
||||
ui.strong(tl!("Korean Composition Settings"));
|
||||
egui::Grid::new("paragraph-style-east-asian").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| {
|
||||
switch(ui, d, "korean_line_break_anywhere", tl!("Korean Line Break Anywhere"), true);
|
||||
|
||||
@@ -158,7 +158,7 @@ fn reset_panel_clears_only_its_section() {
|
||||
let mut app = app();
|
||||
let all = json!({"justify": "Center", "space_after": 6.0, "composer": "singleLine", "roman_hanging": true, "hyphenate": true,
|
||||
"hyphenation": {"words_longer_than": 8}, "justification": {"auto_leading": 150.0}, "tabs": [{"position": 20.0}],
|
||||
"korean_line_break_anywhere": true});
|
||||
"korean_line_break_anywhere": true, "bunri_kinshi": true});
|
||||
app.run("paraStyle.new", json!({"name": "Body", "attrs": all})).unwrap();
|
||||
let stored = def(&app, "Body");
|
||||
for (section, cleared) in [
|
||||
@@ -167,7 +167,7 @@ fn reset_panel_clears_only_its_section() {
|
||||
("composition", &["composer", "roman_hanging"]),
|
||||
("hyphenation", &["hyphenate", "hyphenation"]),
|
||||
("justification", &["justification"]),
|
||||
("eastAsian", &["korean_line_break_anywhere"]),
|
||||
("eastAsian", &["korean_line_break_anywhere", "bunri_kinshi"]),
|
||||
] {
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
assert!(app.run("ui.paraStyleOptions.resetPanel", json!({})).is_err(), "General has no Reset Panel");
|
||||
@@ -286,6 +286,34 @@ fn korean_line_break_anywhere_is_set_by_the_east_asian_section() {
|
||||
assert!(paras(&app, id)[0].korean_line_break_anywhere, "the style's paragraphs take it");
|
||||
}
|
||||
|
||||
/// East Asian Composition Settings › Bunri-Kinshi sets the style's attribute, which its paragraphs
|
||||
/// take; set off again, the style holds it off, and unset, the style leaves it out.
|
||||
#[test]
|
||||
fn bunri_kinshi_is_set_by_the_east_asian_section() {
|
||||
let mut app = app();
|
||||
let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "あ——い"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap();
|
||||
app.run("paraStyle.apply", json!({"name": "Body", "id": id})).unwrap();
|
||||
assert!(!paras(&app, id)[0].bunri_kinshi);
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
assert!(app.ui.dialog.as_ref().unwrap().fields["bunri_kinshi"].is_null());
|
||||
set(&mut app, "section", json!("eastAsian"));
|
||||
set(&mut app, "bunri_kinshi", json!(true));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(def(&app, "Body").get("bunri_kinshi"), Some(&json!(true)));
|
||||
assert!(paras(&app, id)[0].bunri_kinshi, "the style's paragraphs take it");
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["bunri_kinshi"], json!(true));
|
||||
set(&mut app, "bunri_kinshi", json!(false));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(def(&app, "Body").get("bunri_kinshi"), Some(&json!(false)));
|
||||
assert!(!paras(&app, id)[0].bunri_kinshi);
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
set(&mut app, "bunri_kinshi", Value::Null);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(def(&app, "Body").get("bunri_kinshi"), None);
|
||||
}
|
||||
|
||||
/// The rows of the `tabs` field of the open dialog.
|
||||
fn tab_rows(app: &VectorcraftApp) -> Vec<Value> {
|
||||
app.ui.dialog.as_ref().unwrap().fields["tabs"].as_array().cloned().unwrap_or_default()
|
||||
@@ -534,6 +562,7 @@ fn every_section_draws_its_fields() {
|
||||
("hyphenation", "Hyphenation Zone:"),
|
||||
("justification", "Glyph Scaling:"),
|
||||
("eastAsian", "Korean Line Break Anywhere"),
|
||||
("eastAsian", "Bunri-Kinshi"),
|
||||
] {
|
||||
set(&mut app, "section", json!(section));
|
||||
let mut texts = String::new();
|
||||
|
||||
@@ -227,6 +227,9 @@ pub(super) fn option_fields(ui: &mut egui::Ui, d: &mut Dialog, screens: bool) {
|
||||
check(ui, d, "embedFonts", tl!("Embed Fonts (Glyphs Used)"), !d.bool("outlineText") && !tiny);
|
||||
check(ui, d, "fewerTspans", tl!("Fewer <tspan> Elements"), !d.bool("outlineText"));
|
||||
check(ui, d, "metadata", tl!("Include Metadata"), true);
|
||||
if !screens {
|
||||
check(ui, d, "variableData", tl!("Include Variable Data"), true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Use Artboards: all of them or a range; off = the bounds of all art.
|
||||
@@ -443,6 +446,24 @@ mod tests {
|
||||
assert_eq!((written.borrow().len(), written.borrow()[1].0.as_str()), (2, "/tmp/doc.svg"));
|
||||
}
|
||||
|
||||
/// Include Variable Data starts off, draws in the dialog, and writes the text variables and
|
||||
/// data sets into the saved SVG when it is on.
|
||||
#[test]
|
||||
fn include_variable_data_reaches_the_file() {
|
||||
let (mut app, written) = app("/tmp/vars.svg");
|
||||
let id = app.run("text.create", json!({"x": 10, "y": 60, "text": "Alice"})).unwrap()["id"].clone();
|
||||
app.run("variable.define", json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
app.run("variable.bind", json!({"variable": "Name", "ids": [id]})).unwrap();
|
||||
app.run("dataset.new", json!({"name": "Row", "values": {"Name": "Bob"}})).unwrap();
|
||||
app.run("file.saveAs", Value::Null).unwrap();
|
||||
assert!(!app.ui.dialog.as_ref().unwrap().bool("variableData"), "off at first");
|
||||
frame(&mut app);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("variableData".into(), json!(true));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let svg = text(&written, 0);
|
||||
assert!(svg.contains("<variable varName=\"Name\"") && svg.contains("dataSetName=\"Row\"") && svg.contains("<p>Bob</p>"), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_encoding_and_embedded_fonts_reach_the_file() {
|
||||
let (mut app, written) = app("/tmp/tiny.svg");
|
||||
|
||||
@@ -658,7 +658,8 @@ pub fn label_or_name(lang: Lang, s: &str, built_in: bool) -> &str {
|
||||
const MESSAGE_DEPTH: usize = 5;
|
||||
|
||||
/// Translate a status or error message for display (see the module docs). Unknown messages, and
|
||||
/// parts of them (file names, numbers, a reason with no entry), stay as they are.
|
||||
/// parts of them (file names, numbers, a reason with no entry, a JSON object or array such as
|
||||
/// `{"objects": [...]}`), stay as they are.
|
||||
pub fn message(lang: Lang, s: &str) -> String {
|
||||
message_at(lang, s, MESSAGE_DEPTH)
|
||||
}
|
||||
@@ -668,8 +669,9 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String {
|
||||
let count = caps.iter().find(|(k, _)| *k == "n").and_then(|(_, v)| v.parse::<u64>().ok());
|
||||
let form = count.map_or(forms.len().saturating_sub(1), |n| (lang.0.plural)(n).min(forms.len().saturating_sub(1)));
|
||||
let Some(template) = forms.get(form) else { return s.to_string() };
|
||||
let nested = |v: &str| depth > 1 && !v.starts_with(['{', '[']);
|
||||
let values: Vec<(&str, String)> =
|
||||
caps.into_iter().map(|(k, v)| (k, if depth > 1 { message_at(lang, v, depth - 1) } else { v.to_string() })).collect();
|
||||
caps.into_iter().map(|(k, v)| (k, if nested(v) { message_at(lang, v, depth - 1) } else { v.to_string() })).collect();
|
||||
let args: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
fill_once(template, &args)
|
||||
}
|
||||
|
||||
@@ -1634,6 +1634,7 @@ font-technology Variable Променливи
|
||||
Include Hidden Layers Включване на скритите слоеве
|
||||
Include Linked Files Включване на свързаните файлове
|
||||
Include Metadata Включване на метаданни
|
||||
Include Variable Data Включване на данни за променливи
|
||||
Include non-printing layers Включване на слоеве, които не се печат
|
||||
Write pages longer than 200 in at full size in points Записване на страници, по-дълги от 200 in, в пълен размер в точки
|
||||
Include SVG Code Включване на SVG код
|
||||
@@ -2859,6 +2860,7 @@ digits Malayalam Малаялам
|
||||
Roman Baseline Латинска основна линия
|
||||
Roman Hanging Punctuation Латинска висяща пунктуация
|
||||
Korean Line Break Anywhere Пренасяне на корейски текст навсякъде
|
||||
Bunri-Kinshi Bunri-Kinshi
|
||||
East Asian Composition Settings Настройки за източноазиатска композиция
|
||||
Korean Composition Settings Настройки за корейска композиция
|
||||
Rotate Завъртане
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2009,6 +2009,7 @@ digits Malayalam Malajalam
|
||||
Right-to-Left Paragraph Direction Smjer pasusa s desna nalijevo
|
||||
Roman Hanging Punctuation Latinična viseća interpunkcija
|
||||
Korean Line Break Anywhere Prelom korejskog reda bilo gdje
|
||||
Bunri-Kinshi Bunri-Kinshi
|
||||
East Asian Composition Settings Postavke istočnoazijske kompozicije
|
||||
Korean Composition Settings Postavke korejske kompozicije
|
||||
Select a text object to edit its paragraph attributes. Odaberite tekstualni objekat da uredite njegove atribute pasusa.
|
||||
@@ -3092,6 +3093,7 @@ antiAlias Sharp Oštro
|
||||
Hide Code Sakrij kod
|
||||
Images: Slike:
|
||||
Include Metadata Uključi metapodatke
|
||||
Include Variable Data Uključi podatke varijabli
|
||||
Internal CSS Interni CSS
|
||||
Layer Names Nazivi slojeva
|
||||
Minify Minificiraj
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2110,6 +2110,7 @@ digits Malayalam Malaiàlam
|
||||
Include Hidden Layers Inclou les capes ocultes
|
||||
Include Linked Files Inclou els fitxers enllaçats
|
||||
Include Metadata Inclou les metadades
|
||||
Include Variable Data Inclou les dades de variables
|
||||
Include non-printing layers Inclou les capes no imprimibles
|
||||
Write pages longer than 200 in at full size in points Escriu les pàgines de més de 200 in a mida real, en punts
|
||||
Include Objects On Canvas Inclou els objectes del llenç
|
||||
@@ -3671,6 +3672,7 @@ digits Malayalam Malaiàlam
|
||||
Roman Baseline Línia de base romana
|
||||
Roman Hanging Punctuation Puntuació penjant romana
|
||||
Korean Line Break Anywhere Salt de línia coreà a qualsevol lloc
|
||||
Bunri-Kinshi Bunri-Kinshi
|
||||
East Asian Composition Settings Configuració de composició de l’Àsia oriental
|
||||
Korean Composition Settings Configuració de composició coreana
|
||||
Rotate Gira
|
||||
@@ -6089,6 +6091,7 @@ digits Malayalam Malaiàlam
|
||||
@msg unknown option `{path}.{k}` opció desconeguda `{path}.{k}`
|
||||
@msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpressions desconegudes `{o}` (preserve, simulate o discard)
|
||||
@msg unknown panel `{raw}` panell desconegut `{raw}`
|
||||
@msg panels can't be shown in Presentation Mode els panells no es poden mostrar en el mode de presentació
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? paràmetre desconegut `{key}`; volies dir `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters paràmetre desconegut `{key}`; l’ordre no admet cap paràmetre
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} paràmetre desconegut `{key}`; l’ordre admet: {keys}
|
||||
@@ -6195,6 +6198,110 @@ digits Malayalam Malaiàlam
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg {tool} isn't in a toolbar group of several tools {tool} no forma part d’un grup de diverses eines a la barra d’eines
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» demana un pressupost elevat (cada execució fins a {minutes} minuts i {times} vegades les instruccions habituals): una persona l’ha d’aprovar a l’aplicació d’escriptori, o passa `allowHeavy: true` en una sessió iniciada amb --allow-plugin-grants
|
||||
# Plug-in host messages: manifests, modules, runs and plug-in output
|
||||
@msg `name` must be 1-64 printable characters `name` ha de tenir 1-64 caràcters imprimibles
|
||||
@msg `version`, `description` or `author` is too long `version`, `description` o `author` és massa llarg
|
||||
@msg `warnings` must be a list of strings `warnings` ha de ser una llista de cadenes
|
||||
@msg `{k}` must be one of {_1} `{k}` ha de ser un de {_1}
|
||||
@msg `{k}` must be {what} `{k}` ha de ser {what}
|
||||
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un exportador declara d’1 a {MAX_EXTENSIONS} `extensions`
|
||||
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` com a màxim {MAX_UNGROUPED} paràmetres fora de grups: doneu un `group` als altres
|
||||
@msg can't encode the input no es pot codificar l’entrada
|
||||
@msg expected {"dataBase64"} s’esperava {"dataBase64"}
|
||||
@msg expected {"objects"} s’esperava {"objects"}
|
||||
@msg export `{name}`: {e} exportació del mòdul `{name}`: {e}
|
||||
@msg extension {e} is listed twice l’extensió {e} apareix dues vegades
|
||||
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensió {e}: feu servir 1-16 lletres minúscules i dígits, sense el punt
|
||||
@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID no vàlid {_1}: feu servir 1-64 caràcters d’A-Z a-z 0-9 . _ -
|
||||
@msg invalid parameter name {k} nom de paràmetre no vàlid {k}
|
||||
@msg manifest is not UTF-8 el manifest no és UTF-8
|
||||
@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) el manifest és massa llarg (límit {MAX_MANIFEST_BYTES} bytes)
|
||||
@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) el manifest té {_1} bytes (límit {MAX_MANIFEST_BYTES})
|
||||
@msg module is {_1} bytes (limit {_2}) el mòdul té {_1} bytes (límit {_2})
|
||||
@msg needs approval: it asks for {0}, which the user grants when installing it on its own cal aprovació: demana {0}, que l’usuari concedeix en instal·lar-lo per separat
|
||||
@msg not JSON: {e} no és JSON: {e}
|
||||
@msg not a WebAssembly binary (missing \0asm header) no és un binari WebAssembly (falta la capçalera \0asm)
|
||||
@msg parameter `{k}`: {what} paràmetre `{k}`: {what}
|
||||
@msg parameter `{name}`: {what} paràmetre `{name}`: {what}
|
||||
@msg plug-in parameters must be a JSON object els paràmetres del complement han de ser un objecte JSON
|
||||
@msg the module must export its linear memory as `memory` el mòdul ha d’exportar la seva memòria lineal com a `memory`
|
||||
@msg the objects to change are no longer in the document els objectes que calia canviar ja no són al document
|
||||
@msg the runtime panicked l’entorn d’execució ha fallat
|
||||
@msg unexpected host trap aturada inesperada (trap) de l’amfitrió
|
||||
@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version ha retornat {v}; aquest amfitrió implementa la versió {ABI_VERSION}
|
||||
@msg vc_alloc returned 0 (out of memory) vc_alloc ha retornat 0 (sense memòria)
|
||||
@msg vc_alloc returned a block outside memory vc_alloc ha retornat un bloc fora de la memòria
|
||||
@msg vc_run returned error code {packed} vc_run ha retornat el codi d’error {packed}
|
||||
@msg {_1} is not a file {_1} no és un fitxer
|
||||
@msg {_1} is {_2} bytes (limit {_3}) {_1} té {_2} bytes (límit {_3})
|
||||
@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objectes (límit {MAX_OBJECTS})
|
||||
@msg {at}: {msg} {at}: {msg}
|
||||
@msg {what} points outside memory {what} apunta fora de la memòria
|
||||
@msg `angle` must be a number `angle` ha de ser un nombre
|
||||
@msg `aspect` must be from 0.01 to 100 `aspect` ha d’estar entre 0.01 i 100
|
||||
@msg `cap` must be "butt", "round" or "square" `cap` ha de ser "butt", "round" o "square"
|
||||
@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` ha de ser "nonzero" o "evenodd"
|
||||
@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` ha de ser una adreça https:// de {MAX_HELP_URL} bytes com a màxim
|
||||
@msg `help` must be 1-{MAX_HELP} printable characters `help` ha de tenir 1-{MAX_HELP} caràcters imprimibles
|
||||
@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` ha de ser un id d’efecte d’1-{MAX_EFFECT_ID} bytes
|
||||
@msg `id` must be the id of an input object `id` ha de ser l’id d’un objecte d’entrada
|
||||
@msg `join` must be "miter", "round" or "bevel" `join` ha de ser "miter", "round" o "bevel"
|
||||
@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` i `group` han de tenir 1-{MAX_LABEL} caràcters imprimibles
|
||||
@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` ha de ser 1-{MAX_TEXT}, i el valor predeterminat no pot ser més llarg
|
||||
@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` i `maxItems` han de ser 0-{MAX_LIST}, `maxItems` com a mínim 1, i el valor predeterminat ha de tenir aquesta quantitat
|
||||
@msg `name` must be a string `name` ha de ser una cadena
|
||||
@msg `opacity` must be a number `opacity` ha de ser un nombre
|
||||
@msg `type` must be "path" or "compound" `type` ha de ser "path" o "compound"
|
||||
@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` ha de tenir 1-{MAX_UNIT} caràcters imprimibles
|
||||
@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` ha d’indicar d’1 a {MAX_CONDITION_VALUES} valors per a `{other}`
|
||||
@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` anomena `{other}`, que no és un paràmetre declarat abans d’aquest
|
||||
@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` anomena com a màxim {MAX_CONDITIONS} paràmetres
|
||||
@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` pren els valors de `{other}`, d’una casella de selecció o d’una opció
|
||||
@msg `visible` must be true or false `visible` ha de ser true o false
|
||||
@msg `{key}` must be a number from {_1} to {_2} `{key}` ha de ser un nombre de {_1} a {_2}
|
||||
@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` ha de ser una matriu de {MAX_ITEMS} entrades com a màxim
|
||||
@msg a color default must be a color object or a hex string such as "#3a7bd5" un color predeterminat ha de ser un objecte de color o una cadena hexadecimal com ara "#3a7bd5"
|
||||
@msg a color object or a hex string such as "#3a7bd5" un objecte de color o una cadena hexadecimal com ara "#3a7bd5"
|
||||
@msg a freeform gradient holds at most {MAX_ITEMS} points and lines un degradat de forma lliure conté com a màxim {MAX_ITEMS} punts i línies
|
||||
@msg a freeform gradient line names a point it doesn't have una línia del degradat de forma lliure anomena un punt que el degradat no té
|
||||
@msg a gradient needs 1-{MAX_ITEMS} stops un degradat necessita 1-{MAX_ITEMS} parades
|
||||
@msg a list of numbers una llista de nombres
|
||||
@msg a list of {min_items} to {max_items} numbers una llista de {min_items} a {max_items} nombres
|
||||
@msg a new object needs a `path` un objecte nou necessita un `path`
|
||||
@msg a number un nombre
|
||||
@msg a painted live effect returns only `objects` un efecte dinàmic de pintura només retorna `objects`
|
||||
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` una peça d’un efecte dinàmic de pintura només té `path`, `fillRule`, `fills` i `strokes`
|
||||
@msg a read-only object was left out s’ha omès un objecte de només lectura
|
||||
@msg a read-only object was left out: return it, as {"id"} alone or as it was given s’ha omès un objecte de només lectura: retorneu-lo com a {"id"} sol o tal com s’ha rebut
|
||||
@msg a string una cadena
|
||||
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un traç només té `paint`, `width`, `join`, `cap` i `miterLimit`
|
||||
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un traç ha de ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
|
||||
@msg an effect must be {"id", "params"?, "visible"?} un efecte ha de ser {"id", "params"?, "visible"?}
|
||||
@msg an effect on a fill returns only `fills` un efecte en un farciment només retorna `fills`
|
||||
@msg an effect on a stroke returns only `strokes` un efecte en un traç només retorna `strokes`
|
||||
@msg an object must be a JSON object un objecte ha de ser un objecte JSON
|
||||
@msg bad range interval no vàlid
|
||||
@msg choices must be 1-64 non-empty, printable strings les opcions han de ser 1-64 cadenes imprimibles no buides
|
||||
@msg coordinate out of range (limit ±{MAX_COORD} pt) coordenada fora de l’interval (límit ±{MAX_COORD} pt)
|
||||
@msg default is not one of the options el valor predeterminat no és cap de les opcions
|
||||
@msg each piece needs a `path` cada peça necessita un `path`
|
||||
@msg is listed twice apareix dues vegades
|
||||
@msg pattern transform out of range transformació del motiu fora de l’interval
|
||||
@msg read-only field '{field}' changed ha canviat el camp de només lectura '{field}'
|
||||
@msg stops must be in order of `offset` les parades han d’estar en ordre d’`offset`
|
||||
@msg the document has no pattern {pattern} el document no té el motiu {pattern}
|
||||
@msg the document has no swatch {n} el document no té la mostra {n}
|
||||
@msg the output la sortida
|
||||
@msg the path has no anchor points el traçat no té punts d’ancoratge
|
||||
@msg too many anchor points (limit {MAX_ANCHORS}) massa punts d’ancoratge (límit {MAX_ANCHORS})
|
||||
@msg true or false true o false
|
||||
@msg unknown key `{k}`; the keys here are {_1} clau desconeguda `{k}`; les claus aquí són {_1}
|
||||
@msg {_1} is listed twice {_1} apareix dues vegades
|
||||
@msg {_1} is not the id of an input object {_1} no és l’id d’un objecte d’entrada
|
||||
@msg {_1} is read-only {_1} és de només lectura
|
||||
@msg {what} must be from {lo} to {hi} {what} ha d’estar entre {lo} i {hi}
|
||||
@msg a color component un component de color
|
||||
@msg a plate una planxa
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Instal·la «{name}», que demana aquests permisos?
|
||||
ID: {id} ID: {id}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -188,8 +188,10 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Most literal text first, so `Couldn't open {name}: {e}` wins over `{path}: {e}`.
|
||||
c.templates.sort_by_key(|t| std::cmp::Reverse(t.literal));
|
||||
// Templates that start with text before those that start with a placeholder, so
|
||||
// `plug-in manifest: {0}` wins over `{_1} is {_2} bytes (limit {_3})` for a message it
|
||||
// wraps; then most literal text first, so `Couldn't open {name}: {e}` wins over `{path}: {e}`.
|
||||
c.templates.sort_by_key(|t| (matches!(t.pieces.first(), Some(Piece::Hole(_))), std::cmp::Reverse(t.literal)));
|
||||
c
|
||||
}
|
||||
|
||||
|
||||
@@ -2040,6 +2040,7 @@ digits Malayalam Malajálamské
|
||||
Right-to-Left Paragraph Direction Směr odstavce zprava doleva
|
||||
Roman Hanging Punctuation Visící latinková interpunkce
|
||||
Korean Line Break Anywhere Zalomení korejského řádku kdekoli
|
||||
Bunri-Kinshi Bunri-Kinshi
|
||||
East Asian Composition Settings Nastavení východoasijské sazby
|
||||
Korean Composition Settings Nastavení korejské sazby
|
||||
Select a text object to edit its paragraph attributes. Vyberte textový objekt, abyste mohli upravit atributy jeho odstavce.
|
||||
@@ -3128,6 +3129,7 @@ antiAlias Sharp Ostré
|
||||
Hide Code Skrýt kód
|
||||
Images: Obrázky:
|
||||
Include Metadata Zahrnout metadata
|
||||
Include Variable Data Zahrnout data proměnných
|
||||
Internal CSS Interní CSS
|
||||
Layer Names Názvy vrstev
|
||||
Minify Minimalizovat
|
||||
@@ -5118,6 +5120,7 @@ imageTrace Outline Obrys
|
||||
@msg unknown option `{path}.{k}` neznámá možnost `{path}.{k}`
|
||||
@msg unknown overprints `{o}` (preserve, simulate or discard) neznámé přetisky `{o}` (preserve, simulate nebo discard)
|
||||
@msg unknown panel `{raw}` neznámý panel `{raw}`
|
||||
@msg panels can't be shown in Presentation Mode v režimu prezentace nelze panely zobrazit
|
||||
@msg unknown preference `{key}` neznámá předvolba `{key}`
|
||||
@msg Preferences imported. Předvolby byly importovány.
|
||||
@msg not a preferences file: {e} nejde o soubor předvoleb: {e}
|
||||
@@ -6127,6 +6130,110 @@ axis Y Axis osa Y
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` živý efekt běží během vykreslování dokumentu, takže nemůže být `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out exportní zásuvný modul dostává vždy rozpočet exportu: klíč `budget` vynechte
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): musí ho schválit člověk v desktopové aplikaci, nebo předejte `allowHeavy: true` v relaci spuštěné s --allow-plugin-grants
|
||||
# Plug-in host messages: manifests, modules, runs and plug-in output
|
||||
@msg `name` must be 1-64 printable characters `name` musí mít 1-64 tisknutelných znaků
|
||||
@msg `version`, `description` or `author` is too long `version`, `description` nebo `author` je příliš dlouhé
|
||||
@msg `warnings` must be a list of strings `warnings` musí být seznam řetězců
|
||||
@msg `{k}` must be one of {_1} `{k}` musí být jedno z {_1}
|
||||
@msg `{k}` must be {what} `{k}` musí být {what}
|
||||
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` exportní zásuvný modul deklaruje 1 až {MAX_EXTENSIONS} `extensions`
|
||||
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` mimo skupiny smí být nejvýše {MAX_UNGROUPED} parametrů: ostatním přiřaďte `group`
|
||||
@msg can't encode the input vstup nelze zakódovat
|
||||
@msg expected {"dataBase64"} očekáváno {"dataBase64"}
|
||||
@msg expected {"objects"} očekáváno {"objects"}
|
||||
@msg export `{name}`: {e} export `{name}`: {e}
|
||||
@msg extension {e} is listed twice přípona {e} je uvedena dvakrát
|
||||
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot přípona {e}: použijte 1-16 malých písmen a číslic, bez tečky
|
||||
@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - neplatné ID {_1}: použijte 1-64 znaků z A-Z a-z 0-9 . _ -
|
||||
@msg invalid parameter name {k} neplatný název parametru {k}
|
||||
@msg manifest is not UTF-8 manifest není v kódování UTF-8
|
||||
@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) manifest je příliš dlouhý (limit {MAX_MANIFEST_BYTES} bajtů)
|
||||
@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) manifest má {_1} bajtů (limit {MAX_MANIFEST_BYTES})
|
||||
@msg module is {_1} bytes (limit {_2}) modul má {_1} bajtů (limit {_2})
|
||||
@msg needs approval: it asks for {0}, which the user grants when installing it on its own vyžaduje schválení: žádá o {0}, což uživatel povolí, když ho instaluje samostatně
|
||||
@msg not JSON: {e} není JSON: {e}
|
||||
@msg not a WebAssembly binary (missing \0asm header) není binární soubor WebAssembly (chybí hlavička \0asm)
|
||||
@msg parameter `{k}`: {what} parametr `{k}`: {what}
|
||||
@msg parameter `{name}`: {what} parametr `{name}`: {what}
|
||||
@msg plug-in parameters must be a JSON object parametry zásuvného modulu musí být objekt JSON
|
||||
@msg the module must export its linear memory as `memory` modul musí exportovat svou lineární paměť jako `memory`
|
||||
@msg the objects to change are no longer in the document objekty ke změně už v dokumentu nejsou
|
||||
@msg the runtime panicked běhové prostředí havarovalo
|
||||
@msg unexpected host trap neočekávaná chyba (trap) hostitele
|
||||
@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version vrátila {v}; tento hostitel implementuje verzi {ABI_VERSION}
|
||||
@msg vc_alloc returned 0 (out of memory) vc_alloc vrátila 0 (nedostatek paměti)
|
||||
@msg vc_alloc returned a block outside memory vc_alloc vrátila blok mimo paměť
|
||||
@msg vc_run returned error code {packed} vc_run vrátila chybový kód {packed}
|
||||
@msg {_1} is not a file {_1} není soubor
|
||||
@msg {_1} is {_2} bytes (limit {_3}) {_1} má {_2} bajtů (limit {_3})
|
||||
@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objektů (limit {MAX_OBJECTS})
|
||||
@msg {at}: {msg} {at}: {msg}
|
||||
@msg {what} points outside memory {what} ukazuje mimo paměť
|
||||
@msg `angle` must be a number `angle` musí být číslo
|
||||
@msg `aspect` must be from 0.01 to 100 `aspect` musí být od 0.01 do 100
|
||||
@msg `cap` must be "butt", "round" or "square" `cap` musí být "butt", "round" nebo "square"
|
||||
@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` musí být "nonzero" nebo "evenodd"
|
||||
@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` musí být adresa https:// o nejvýše {MAX_HELP_URL} bajtech
|
||||
@msg `help` must be 1-{MAX_HELP} printable characters `help` musí mít 1-{MAX_HELP} tisknutelných znaků
|
||||
@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` musí být ID efektu o 1-{MAX_EFFECT_ID} bajtech
|
||||
@msg `id` must be the id of an input object `id` musí být ID vstupního objektu
|
||||
@msg `join` must be "miter", "round" or "bevel" `join` musí být "miter", "round" nebo "bevel"
|
||||
@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` a `group` musí mít 1-{MAX_LABEL} tisknutelných znaků
|
||||
@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` musí být 1-{MAX_TEXT} a výchozí hodnota nesmí být delší
|
||||
@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` a `maxItems` musí být 0-{MAX_LIST}, `maxItems` alespoň 1 a výchozí hodnota musí mít tolik položek
|
||||
@msg `name` must be a string `name` musí být řetězec
|
||||
@msg `opacity` must be a number `opacity` musí být číslo
|
||||
@msg `type` must be "path" or "compound" `type` musí být "path" nebo "compound"
|
||||
@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` musí mít 1-{MAX_UNIT} tisknutelných znaků
|
||||
@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` musí uvádět 1 až {MAX_CONDITION_VALUES} hodnot pro `{other}`
|
||||
@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` uvádí `{other}`, což není parametr deklarovaný před tímto
|
||||
@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` smí uvádět nejvýše {MAX_CONDITIONS} parametrů
|
||||
@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` přebírá hodnoty `{other}`, a to zaškrtávacího políčka nebo volby
|
||||
@msg `visible` must be true or false `visible` musí být true nebo false
|
||||
@msg `{key}` must be a number from {_1} to {_2} `{key}` musí být číslo od {_1} do {_2}
|
||||
@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` musí být pole s nejvýše {MAX_ITEMS} položkami
|
||||
@msg a color default must be a color object or a hex string such as "#3a7bd5" výchozí barva musí být objekt barvy nebo šestnáctkový řetězec, například "#3a7bd5"
|
||||
@msg a color object or a hex string such as "#3a7bd5" objekt barvy nebo šestnáctkový řetězec, například "#3a7bd5"
|
||||
@msg a freeform gradient holds at most {MAX_ITEMS} points and lines volný přechod obsahuje nejvýše {MAX_ITEMS} bodů a čar
|
||||
@msg a freeform gradient line names a point it doesn't have čára volného přechodu uvádí bod, který přechod nemá
|
||||
@msg a gradient needs 1-{MAX_ITEMS} stops přechod potřebuje 1-{MAX_ITEMS} zarážek
|
||||
@msg a list of numbers seznam čísel
|
||||
@msg a list of {min_items} to {max_items} numbers seznam {min_items} až {max_items} čísel
|
||||
@msg a new object needs a `path` nový objekt potřebuje `path`
|
||||
@msg a number číslo
|
||||
@msg a painted live effect returns only `objects` malující živý efekt vrací jen `objects`
|
||||
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` část malujícího živého efektu má jen `path`, `fillRule`, `fills` a `strokes`
|
||||
@msg a read-only object was left out objekt jen pro čtení byl vynechán
|
||||
@msg a read-only object was left out: return it, as {"id"} alone or as it was given objekt jen pro čtení byl vynechán: vraťte ho jako samotné {"id"} nebo tak, jak byl předán
|
||||
@msg a string řetězec
|
||||
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` tah má jen `paint`, `width`, `join`, `cap` a `miterLimit`
|
||||
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} tah musí být {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
|
||||
@msg an effect must be {"id", "params"?, "visible"?} efekt musí být {"id", "params"?, "visible"?}
|
||||
@msg an effect on a fill returns only `fills` efekt na výplni vrací jen `fills`
|
||||
@msg an effect on a stroke returns only `strokes` efekt na tahu vrací jen `strokes`
|
||||
@msg an object must be a JSON object objekt musí být objekt JSON
|
||||
@msg bad range neplatný rozsah
|
||||
@msg choices must be 1-64 non-empty, printable strings volby musí být 1-64 neprázdných tisknutelných řetězců
|
||||
@msg coordinate out of range (limit ±{MAX_COORD} pt) souřadnice mimo rozsah (limit ±{MAX_COORD} pt)
|
||||
@msg default is not one of the options výchozí hodnota není jednou z možností
|
||||
@msg each piece needs a `path` každá část potřebuje `path`
|
||||
@msg is listed twice je uveden dvakrát
|
||||
@msg pattern transform out of range transformace vzorku mimo rozsah
|
||||
@msg read-only field '{field}' changed pole jen pro čtení '{field}' bylo změněno
|
||||
@msg stops must be in order of `offset` zarážky musí být seřazeny podle `offset`
|
||||
@msg the document has no pattern {pattern} dokument nemá vzorek {pattern}
|
||||
@msg the document has no swatch {n} dokument nemá vzorek barvy {n}
|
||||
@msg the output výstup
|
||||
@msg the path has no anchor points cesta nemá žádné kotevní body
|
||||
@msg too many anchor points (limit {MAX_ANCHORS}) příliš mnoho kotevních bodů (limit {MAX_ANCHORS})
|
||||
@msg true or false true nebo false
|
||||
@msg unknown key `{k}`; the keys here are {_1} neznámý klíč `{k}`; zde platné klíče jsou {_1}
|
||||
@msg {_1} is listed twice {_1} je uveden dvakrát
|
||||
@msg {_1} is not the id of an input object {_1} není ID vstupního objektu
|
||||
@msg {_1} is read-only {_1} je jen pro čtení
|
||||
@msg {what} must be from {lo} to {hi} {what} musí být od {lo} do {hi}
|
||||
@msg a color component složka barvy
|
||||
@msg a plate deska
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Nainstalovat „{name}“, který žádá o tato oprávnění?
|
||||
ID: {id} ID: {id}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1842,6 +1842,7 @@ digits Malayalam Malayalam
|
||||
Right-to-Left Paragraph Direction Afsnitsretning fra højre mod venstre
|
||||
Roman Hanging Punctuation Romersk hængende tegnsætning
|
||||
Korean Line Break Anywhere Koreansk linjeskift hvor som helst
|
||||
Bunri-Kinshi Bunri-Kinshi
|
||||
East Asian Composition Settings Indstillinger for østasiatisk komposition
|
||||
Korean Composition Settings Indstillinger for koreansk komposition
|
||||
Select a text object to edit its paragraph attributes. Markér et tekstobjekt for at redigere dets afsnitsattributter.
|
||||
@@ -2912,6 +2913,7 @@ antiAlias Sharp Skarp
|
||||
Hide Code Skjul kode
|
||||
Images: Billeder:
|
||||
Include Metadata Medtag metadata
|
||||
Include Variable Data Medtag variabeldata
|
||||
Internal CSS Intern CSS
|
||||
Layer Names Lagnavne
|
||||
Minify Minificér
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1693,6 +1693,7 @@ file-filter Templates Vorlagen
|
||||
Include Hidden Layers Ausgeblendete Ebenen einschließen
|
||||
Include Linked Files Verknüpfte Dateien einschließen
|
||||
Include Metadata Metadaten einschließen
|
||||
Include Variable Data Variablendaten einschließen
|
||||
Include non-printing layers Nicht druckende Ebenen einschließen
|
||||
Write pages longer than 200 in at full size in points Seiten über 200 in in voller Größe in Punkt schreiben
|
||||
Include SVG Code SVG-Code einschließen
|
||||
@@ -2905,6 +2906,7 @@ digits Malayalam Malayalam
|
||||
Roman Baseline Lateinische Grundlinie
|
||||
Roman Hanging Punctuation Hängende Interpunktion (lateinisch)
|
||||
Korean Line Break Anywhere Koreanischer Zeilenumbruch überall
|
||||
Bunri-Kinshi Bunri-Kinshi
|
||||
East Asian Composition Settings Einstellungen für ostasiatischen Satz
|
||||
Korean Composition Settings Einstellungen für koreanischen Satz
|
||||
Rotate Drehen
|
||||
@@ -4735,6 +4737,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg unknown option `{path}.{k}` unbekannte Option `{path}.{k}`
|
||||
@msg unknown overprints `{o}` (preserve, simulate or discard) unbekannter Wert für overprints `{o}` (preserve, simulate oder discard)
|
||||
@msg unknown panel `{raw}` unbekanntes Bedienfeld `{raw}`
|
||||
@msg panels can't be shown in Presentation Mode im Präsentationsmodus können keine Bedienfelder angezeigt werden
|
||||
@msg unknown preference `{key}` unbekannte Voreinstellung `{key}`
|
||||
@msg Preferences imported. Voreinstellungen importiert.
|
||||
@msg not a preferences file: {e} keine Voreinstellungsdatei: {e}
|
||||
@@ -5718,6 +5721,110 @@ axis Y Axis Y-Achse
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` ein Live-Effekt läuft, während das Dokument gezeichnet wird, daher kann er nicht `heavy` sein
|
||||
@msg an exporter always gets an export's budget: leave `budget` out ein Export-Plug-in erhält immer das Budget eines Exports: Lassen Sie `budget` weg
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Eine Person muss es in der Desktop-App genehmigen, oder übergeben Sie `allowHeavy: true` in einer mit --allow-plugin-grants gestarteten Sitzung
|
||||
# Plug-in host messages: manifests, modules, runs and plug-in output
|
||||
@msg `name` must be 1-64 printable characters `name` muss 1-64 druckbare Zeichen haben
|
||||
@msg `version`, `description` or `author` is too long `version`, `description` oder `author` ist zu lang
|
||||
@msg `warnings` must be a list of strings `warnings` muss eine Liste von Zeichenketten sein
|
||||
@msg `{k}` must be one of {_1} `{k}` muss einer der folgenden Werte sein: {_1}
|
||||
@msg `{k}` must be {what} `{k}` muss {what} sein
|
||||
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ein Export-Plug-in gibt 1 bis {MAX_EXTENSIONS} `extensions` an
|
||||
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` höchstens {MAX_UNGROUPED} Parameter außerhalb von Gruppen: Geben Sie den anderen eine `group`
|
||||
@msg can't encode the input die Eingabe kann nicht codiert werden
|
||||
@msg expected {"dataBase64"} erwartet: {"dataBase64"}
|
||||
@msg expected {"objects"} erwartet: {"objects"}
|
||||
@msg export `{name}`: {e} Modulexport `{name}`: {e}
|
||||
@msg extension {e} is listed twice die Dateiendung {e} ist doppelt aufgeführt
|
||||
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot Dateiendung {e}: Verwenden Sie 1-16 Kleinbuchstaben und Ziffern, ohne Punkt
|
||||
@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ungültige ID {_1}: Verwenden Sie 1-64 Zeichen aus A-Z a-z 0-9 . _ -
|
||||
@msg invalid parameter name {k} ungültiger Parametername {k}
|
||||
@msg manifest is not UTF-8 das Manifest ist nicht in UTF-8 codiert
|
||||
@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) das Manifest ist zu lang (Grenze {MAX_MANIFEST_BYTES} Byte)
|
||||
@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) das Manifest ist {_1} Byte groß (Grenze {MAX_MANIFEST_BYTES})
|
||||
@msg module is {_1} bytes (limit {_2}) das Modul ist {_1} Byte groß (Grenze {_2})
|
||||
@msg needs approval: it asks for {0}, which the user grants when installing it on its own muss genehmigt werden: Es verlangt {0}, was der Benutzer gewährt, wenn er es einzeln installiert
|
||||
@msg not JSON: {e} kein JSON: {e}
|
||||
@msg not a WebAssembly binary (missing \0asm header) keine WebAssembly-Binärdatei (der Header \0asm fehlt)
|
||||
@msg parameter `{k}`: {what} Parameter `{k}`: {what}
|
||||
@msg parameter `{name}`: {what} Parameter `{name}`: {what}
|
||||
@msg plug-in parameters must be a JSON object die Plug-in-Parameter müssen ein JSON-Objekt sein
|
||||
@msg the module must export its linear memory as `memory` das Modul muss seinen linearen Speicher als `memory` exportieren
|
||||
@msg the objects to change are no longer in the document die zu ändernden Objekte sind nicht mehr im Dokument
|
||||
@msg the runtime panicked die Laufzeitumgebung ist abgestürzt
|
||||
@msg unexpected host trap unerwarteter Abbruch (Trap) im Host
|
||||
@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version hat {v} zurückgegeben; dieser Host unterstützt Version {ABI_VERSION}
|
||||
@msg vc_alloc returned 0 (out of memory) vc_alloc hat 0 zurückgegeben (kein Speicher mehr)
|
||||
@msg vc_alloc returned a block outside memory vc_alloc hat einen Block außerhalb des Speichers zurückgegeben
|
||||
@msg vc_run returned error code {packed} vc_run hat den Fehlercode {packed} zurückgegeben
|
||||
@msg {_1} is not a file {_1} ist keine Datei
|
||||
@msg {_1} is {_2} bytes (limit {_3}) {_1} ist {_2} Byte groß (Grenze {_3})
|
||||
@msg {_1} objects (limit {MAX_OBJECTS}) {_1} Objekte (Grenze {MAX_OBJECTS})
|
||||
@msg {at}: {msg} {at}: {msg}
|
||||
@msg {what} points outside memory {what} zeigt außerhalb des Speichers
|
||||
@msg `angle` must be a number `angle` muss eine Zahl sein
|
||||
@msg `aspect` must be from 0.01 to 100 `aspect` muss zwischen 0.01 und 100 liegen
|
||||
@msg `cap` must be "butt", "round" or "square" `cap` muss "butt", "round" oder "square" sein
|
||||
@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` muss "nonzero" oder "evenodd" sein
|
||||
@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` muss eine https://-Adresse mit höchstens {MAX_HELP_URL} Byte sein
|
||||
@msg `help` must be 1-{MAX_HELP} printable characters `help` muss 1-{MAX_HELP} druckbare Zeichen haben
|
||||
@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` muss eine Effekt-ID mit 1-{MAX_EFFECT_ID} Byte sein
|
||||
@msg `id` must be the id of an input object `id` muss die ID eines Eingabeobjekts sein
|
||||
@msg `join` must be "miter", "round" or "bevel" `join` muss "miter", "round" oder "bevel" sein
|
||||
@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` und `group` müssen 1-{MAX_LABEL} druckbare Zeichen haben
|
||||
@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` muss 1-{MAX_TEXT} sein, und der Standardwert darf nicht länger sein
|
||||
@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` und `maxItems` müssen 0-{MAX_LIST} sein, `maxItems` mindestens 1, und der Standardwert muss so viele Einträge haben
|
||||
@msg `name` must be a string `name` muss eine Zeichenkette sein
|
||||
@msg `opacity` must be a number `opacity` muss eine Zahl sein
|
||||
@msg `type` must be "path" or "compound" `type` muss "path" oder "compound" sein
|
||||
@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` muss 1-{MAX_UNIT} druckbare Zeichen haben
|
||||
@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` muss 1 bis {MAX_CONDITION_VALUES} Werte für `{other}` nennen
|
||||
@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nennt `{other}`, aber das ist kein Parameter, der vor diesem deklariert ist
|
||||
@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` darf höchstens {MAX_CONDITIONS} Parameter nennen
|
||||
@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` nimmt die Werte von `{other}` an, die eines Kontrollkästchens oder einer Auswahl
|
||||
@msg `visible` must be true or false `visible` muss true oder false sein
|
||||
@msg `{key}` must be a number from {_1} to {_2} `{key}` muss eine Zahl von {_1} bis {_2} sein
|
||||
@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` muss ein Array mit höchstens {MAX_ITEMS} Einträgen sein
|
||||
@msg a color default must be a color object or a hex string such as "#3a7bd5" ein Farb-Standardwert muss ein Farbobjekt oder ein Hex-String wie "#3a7bd5" sein
|
||||
@msg a color object or a hex string such as "#3a7bd5" ein Farbobjekt oder ein Hex-String wie "#3a7bd5"
|
||||
@msg a freeform gradient holds at most {MAX_ITEMS} points and lines ein Freiform-Verlauf enthält höchstens {MAX_ITEMS} Punkte und Linien
|
||||
@msg a freeform gradient line names a point it doesn't have eine Linie des Freiform-Verlaufs nennt einen Punkt, den der Verlauf nicht hat
|
||||
@msg a gradient needs 1-{MAX_ITEMS} stops ein Verlauf braucht 1-{MAX_ITEMS} Farbstopps
|
||||
@msg a list of numbers eine Liste von Zahlen
|
||||
@msg a list of {min_items} to {max_items} numbers eine Liste von {min_items} bis {max_items} Zahlen
|
||||
@msg a new object needs a `path` ein neues Objekt braucht einen `path`
|
||||
@msg a number eine Zahl
|
||||
@msg a painted live effect returns only `objects` ein malender Live-Effekt gibt nur `objects` zurück
|
||||
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ein Teil eines malenden Live-Effekts hat nur `path`, `fillRule`, `fills` und `strokes`
|
||||
@msg a read-only object was left out ein schreibgeschütztes Objekt wurde weggelassen
|
||||
@msg a read-only object was left out: return it, as {"id"} alone or as it was given ein schreibgeschütztes Objekt wurde weggelassen: Geben Sie es zurück, als {"id"} allein oder so, wie es übergeben wurde
|
||||
@msg a string eine Zeichenkette
|
||||
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` eine Kontur hat nur `paint`, `width`, `join`, `cap` und `miterLimit`
|
||||
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} eine Kontur muss {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} sein
|
||||
@msg an effect must be {"id", "params"?, "visible"?} ein Effekt muss {"id", "params"?, "visible"?} sein
|
||||
@msg an effect on a fill returns only `fills` ein Effekt auf einer Fläche gibt nur `fills` zurück
|
||||
@msg an effect on a stroke returns only `strokes` ein Effekt auf einer Kontur gibt nur `strokes` zurück
|
||||
@msg an object must be a JSON object ein Objekt muss ein JSON-Objekt sein
|
||||
@msg bad range ungültiger Bereich
|
||||
@msg choices must be 1-64 non-empty, printable strings die Auswahlwerte müssen 1-64 nicht leere, druckbare Zeichenketten sein
|
||||
@msg coordinate out of range (limit ±{MAX_COORD} pt) Koordinate außerhalb des Bereichs (Grenze ±{MAX_COORD} pt)
|
||||
@msg default is not one of the options der Standardwert ist keine der Optionen
|
||||
@msg each piece needs a `path` jedes Teil braucht einen `path`
|
||||
@msg is listed twice ist doppelt aufgeführt
|
||||
@msg pattern transform out of range Mustertransformation außerhalb des Bereichs
|
||||
@msg read-only field '{field}' changed das schreibgeschützte Feld '{field}' wurde geändert
|
||||
@msg stops must be in order of `offset` die Farbstopps müssen nach `offset` geordnet sein
|
||||
@msg the document has no pattern {pattern} das Dokument hat kein Muster {pattern}
|
||||
@msg the document has no swatch {n} das Dokument hat kein Farbfeld {n}
|
||||
@msg the output die Ausgabe
|
||||
@msg the path has no anchor points der Pfad hat keine Ankerpunkte
|
||||
@msg too many anchor points (limit {MAX_ANCHORS}) zu viele Ankerpunkte (Grenze {MAX_ANCHORS})
|
||||
@msg true or false true oder false
|
||||
@msg unknown key `{k}`; the keys here are {_1} unbekannter Schlüssel `{k}`; die Schlüssel hier sind {_1}
|
||||
@msg {_1} is listed twice {_1} ist doppelt aufgeführt
|
||||
@msg {_1} is not the id of an input object {_1} ist nicht die ID eines Eingabeobjekts
|
||||
@msg {_1} is read-only {_1} ist schreibgeschützt
|
||||
@msg {what} must be from {lo} to {hi} {what} muss zwischen {lo} und {hi} liegen
|
||||
@msg a color component eine Farbkomponente
|
||||
@msg a plate eine Druckplatte
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? „{name}“ installieren, das diese Berechtigungen verlangt?
|
||||
ID: {id} ID: {id}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1608,6 +1608,7 @@ font-technology Variable Μεταβλητές
|
||||
Include Hidden Layers Συμπερίληψη κρυφών επιπέδων
|
||||
Include Linked Files Συμπερίληψη συνδεδεμένων αρχείων
|
||||
Include Metadata Συμπερίληψη μεταδεδομένων
|
||||
Include Variable Data Συμπερίληψη δεδομένων μεταβλητών
|
||||
Include non-printing layers Συμπερίληψη μη εκτυπώσιμων επιπέδων
|
||||
Write pages longer than 200 in at full size in points Εγγραφή σελίδων μεγαλύτερων από 200 in σε πλήρες μέγεθος, σε στιγμές
|
||||
Include SVG Code Συμπερίληψη κώδικα SVG
|
||||
@@ -1978,6 +1979,7 @@ font-technology Variable Μεταβλητές
|
||||
Rise… Ανύψωση…
|
||||
Roman Hanging Punctuation Κρεμαστή στίξη όρθιας γραφής
|
||||
Korean Line Break Anywhere Αλλαγή γραμμής κορεατικών οπουδήποτε
|
||||
Bunri-Kinshi Bunri-Kinshi
|
||||
East Asian Composition Settings Ρυθμίσεις σύνθεσης ανατολικής Ασίας
|
||||
Korean Composition Settings Ρυθμίσεις κορεατικής σύνθεσης
|
||||
Rotate Περιστροφή
|
||||
@@ -4757,6 +4759,7 @@ digits Malayalam Μαλαγιάλαμ
|
||||
@msg unknown option `{path}.{k}` άγνωστη επιλογή `{path}.{k}`
|
||||
@msg unknown overprints `{o}` (preserve, simulate or discard) άγνωστη επιλογή υπερεκτύπωσης `{o}` (preserve, simulate ή discard)
|
||||
@msg unknown panel `{raw}` άγνωστος πίνακας `{raw}`
|
||||
@msg panels can't be shown in Presentation Mode τα πλαίσια δεν μπορούν να εμφανιστούν στη λειτουργία παρουσίασης
|
||||
@msg unknown preference `{key}` άγνωστη προτίμηση `{key}`
|
||||
@msg Preferences imported. Οι προτιμήσεις εισήχθησαν.
|
||||
@msg not a preferences file: {e} δεν είναι αρχείο προτιμήσεων: {e}
|
||||
@@ -6078,6 +6081,110 @@ axis Y Axis Άξονας Y
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` ένα δυναμικό εφέ εκτελείται κατά τη σχεδίαση του εγγράφου, οπότε δεν μπορεί να είναι `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out ένα πρόσθετο εξαγωγής παίρνει πάντα τον προϋπολογισμό μιας εξαγωγής: παραλείψτε το `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): πρέπει να το εγκρίνει ένα άτομο στην εφαρμογή επιφάνειας εργασίας ή περάστε `allowHeavy: true` σε μια συνεδρία που ξεκίνησε με --allow-plugin-grants
|
||||
# Plug-in host messages: manifests, modules, runs and plug-in output
|
||||
@msg `name` must be 1-64 printable characters το `name` πρέπει να έχει 1-64 εκτυπώσιμους χαρακτήρες
|
||||
@msg `version`, `description` or `author` is too long το `version`, το `description` ή το `author` είναι πολύ μεγάλο
|
||||
@msg `warnings` must be a list of strings το `warnings` πρέπει να είναι λίστα συμβολοσειρών
|
||||
@msg `{k}` must be one of {_1} το `{k}` πρέπει να είναι ένα από τα {_1}
|
||||
@msg `{k}` must be {what} το `{k}` πρέπει να είναι {what}
|
||||
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ένα πρόσθετο εξαγωγής δηλώνει 1 έως {MAX_EXTENSIONS} `extensions`
|
||||
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` το πολύ {MAX_UNGROUPED} παράμετροι εκτός ομάδων: δώστε στις υπόλοιπες ένα `group`
|
||||
@msg can't encode the input η είσοδος δεν μπορεί να κωδικοποιηθεί
|
||||
@msg expected {"dataBase64"} αναμενόταν {"dataBase64"}
|
||||
@msg expected {"objects"} αναμενόταν {"objects"}
|
||||
@msg export `{name}`: {e} εξαγωγή `{name}`: {e}
|
||||
@msg extension {e} is listed twice η επέκταση {e} αναφέρεται δύο φορές
|
||||
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot επέκταση {e}: χρησιμοποιήστε 1-16 πεζά γράμματα και ψηφία, χωρίς την τελεία
|
||||
@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - μη έγκυρο αναγνωριστικό {_1}: χρησιμοποιήστε 1-64 χαρακτήρες από A-Z a-z 0-9 . _ -
|
||||
@msg invalid parameter name {k} μη έγκυρο όνομα παραμέτρου {k}
|
||||
@msg manifest is not UTF-8 το αρχείο δηλώσεων δεν είναι σε UTF-8
|
||||
@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) το αρχείο δηλώσεων είναι πολύ μεγάλο (όριο {MAX_MANIFEST_BYTES} byte)
|
||||
@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) το αρχείο δηλώσεων έχει {_1} byte (όριο {MAX_MANIFEST_BYTES})
|
||||
@msg module is {_1} bytes (limit {_2}) η λειτουργική μονάδα έχει {_1} byte (όριο {_2})
|
||||
@msg needs approval: it asks for {0}, which the user grants when installing it on its own χρειάζεται έγκριση: ζητά {0}, που ο χρήστης παραχωρεί όταν το εγκαθιστά μόνο του
|
||||
@msg not JSON: {e} δεν είναι JSON: {e}
|
||||
@msg not a WebAssembly binary (missing \0asm header) δεν είναι δυαδικό αρχείο WebAssembly (λείπει η κεφαλίδα \0asm)
|
||||
@msg parameter `{k}`: {what} παράμετρος `{k}`: {what}
|
||||
@msg parameter `{name}`: {what} παράμετρος `{name}`: {what}
|
||||
@msg plug-in parameters must be a JSON object οι παράμετροι του προσθέτου πρέπει να είναι αντικείμενο JSON
|
||||
@msg the module must export its linear memory as `memory` η λειτουργική μονάδα πρέπει να εξάγει τη γραμμική της μνήμη ως `memory`
|
||||
@msg the objects to change are no longer in the document τα αντικείμενα προς αλλαγή δεν βρίσκονται πια στο έγγραφο
|
||||
@msg the runtime panicked το περιβάλλον εκτέλεσης κατέρρευσε
|
||||
@msg unexpected host trap μη αναμενόμενη παγίδευση του κεντρικού υπολογιστή
|
||||
@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} η vc_abi_version επέστρεψε {v}· αυτός ο κεντρικός υπολογιστής υλοποιεί την έκδοση {ABI_VERSION}
|
||||
@msg vc_alloc returned 0 (out of memory) η vc_alloc επέστρεψε 0 (η μνήμη εξαντλήθηκε)
|
||||
@msg vc_alloc returned a block outside memory η vc_alloc επέστρεψε ένα μπλοκ εκτός μνήμης
|
||||
@msg vc_run returned error code {packed} η vc_run επέστρεψε τον κωδικό σφάλματος {packed}
|
||||
@msg {_1} is not a file το {_1} δεν είναι αρχείο
|
||||
@msg {_1} is {_2} bytes (limit {_3}) το {_1} έχει {_2} byte (όριο {_3})
|
||||
@msg {_1} objects (limit {MAX_OBJECTS}) {_1} αντικείμενα (όριο {MAX_OBJECTS})
|
||||
@msg {at}: {msg} {at}: {msg}
|
||||
@msg {what} points outside memory το {what} δείχνει εκτός μνήμης
|
||||
@msg `angle` must be a number το `angle` πρέπει να είναι αριθμός
|
||||
@msg `aspect` must be from 0.01 to 100 το `aspect` πρέπει να είναι από 0.01 έως 100
|
||||
@msg `cap` must be "butt", "round" or "square" το `cap` πρέπει να είναι "butt", "round" ή "square"
|
||||
@msg `fillRule` must be "nonzero" or "evenodd" το `fillRule` πρέπει να είναι "nonzero" ή "evenodd"
|
||||
@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes το `helpUrl` πρέπει να είναι διεύθυνση https:// έως {MAX_HELP_URL} byte
|
||||
@msg `help` must be 1-{MAX_HELP} printable characters το `help` πρέπει να έχει 1-{MAX_HELP} εκτυπώσιμους χαρακτήρες
|
||||
@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes το `id` πρέπει να είναι αναγνωριστικό εφέ 1-{MAX_EFFECT_ID} byte
|
||||
@msg `id` must be the id of an input object το `id` πρέπει να είναι το αναγνωριστικό ενός αντικειμένου εισόδου
|
||||
@msg `join` must be "miter", "round" or "bevel" το `join` πρέπει να είναι "miter", "round" ή "bevel"
|
||||
@msg `label` and `group` must be 1-{MAX_LABEL} printable characters τα `label` και `group` πρέπει να έχουν 1-{MAX_LABEL} εκτυπώσιμους χαρακτήρες
|
||||
@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer το `maxLength` πρέπει να είναι 1-{MAX_TEXT} και η προεπιλογή όχι μεγαλύτερη
|
||||
@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many τα `minItems` και `maxItems` πρέπει να είναι 0-{MAX_LIST}, το `maxItems` τουλάχιστον 1 και η προεπιλογή να έχει τόσα στοιχεία
|
||||
@msg `name` must be a string το `name` πρέπει να είναι συμβολοσειρά
|
||||
@msg `opacity` must be a number το `opacity` πρέπει να είναι αριθμός
|
||||
@msg `type` must be "path" or "compound" το `type` πρέπει να είναι "path" ή "compound"
|
||||
@msg `unit` must be 1-{MAX_UNIT} printable characters το `unit` πρέπει να έχει 1-{MAX_UNIT} εκτυπώσιμους χαρακτήρες
|
||||
@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` το `visibleWhen` πρέπει να αναφέρει 1 έως {MAX_CONDITION_VALUES} τιμές για το `{other}`
|
||||
@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one το `visibleWhen` αναφέρει το `{other}`, που δεν είναι παράμετρος δηλωμένη πριν από αυτήν
|
||||
@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters το `visibleWhen` αναφέρει έως {MAX_CONDITIONS} παραμέτρους
|
||||
@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice το `visibleWhen` δέχεται τιμές του `{other}`, ενός πλαισίου ελέγχου ή μιας επιλογής
|
||||
@msg `visible` must be true or false το `visible` πρέπει να είναι true ή false
|
||||
@msg `{key}` must be a number from {_1} to {_2} το `{key}` πρέπει να είναι αριθμός από {_1} έως {_2}
|
||||
@msg `{key}` must be an array of at most {MAX_ITEMS} entries το `{key}` πρέπει να είναι πίνακας έως {MAX_ITEMS} στοιχείων
|
||||
@msg a color default must be a color object or a hex string such as "#3a7bd5" η προεπιλογή χρώματος πρέπει να είναι αντικείμενο χρώματος ή δεκαεξαδική συμβολοσειρά όπως "#3a7bd5"
|
||||
@msg a color object or a hex string such as "#3a7bd5" αντικείμενο χρώματος ή δεκαεξαδική συμβολοσειρά όπως "#3a7bd5"
|
||||
@msg a freeform gradient holds at most {MAX_ITEMS} points and lines ένα ντεγκραντέ ελεύθερης μορφής έχει έως {MAX_ITEMS} σημεία και γραμμές
|
||||
@msg a freeform gradient line names a point it doesn't have μια γραμμή ντεγκραντέ ελεύθερης μορφής αναφέρει σημείο που το ντεγκραντέ δεν έχει
|
||||
@msg a gradient needs 1-{MAX_ITEMS} stops ένα ντεγκραντέ χρειάζεται 1-{MAX_ITEMS} σημεία χρώματος
|
||||
@msg a list of numbers λίστα αριθμών
|
||||
@msg a list of {min_items} to {max_items} numbers λίστα {min_items} έως {max_items} αριθμών
|
||||
@msg a new object needs a `path` ένα νέο αντικείμενο χρειάζεται `path`
|
||||
@msg a number αριθμός
|
||||
@msg a painted live effect returns only `objects` ένα ζωντανό εφέ βαφής επιστρέφει μόνο `objects`
|
||||
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ένα κομμάτι ζωντανού εφέ βαφής έχει μόνο `path`, `fillRule`, `fills` και `strokes`
|
||||
@msg a read-only object was left out παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση
|
||||
@msg a read-only object was left out: return it, as {"id"} alone or as it was given παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση: επιστρέψτε το ως {"id"} μόνο ή όπως δόθηκε
|
||||
@msg a string συμβολοσειρά
|
||||
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` ένα περίγραμμα έχει μόνο `paint`, `width`, `join`, `cap` και `miterLimit`
|
||||
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} ένα περίγραμμα πρέπει να είναι {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
|
||||
@msg an effect must be {"id", "params"?, "visible"?} ένα εφέ πρέπει να είναι {"id", "params"?, "visible"?}
|
||||
@msg an effect on a fill returns only `fills` ένα εφέ σε γέμισμα επιστρέφει μόνο `fills`
|
||||
@msg an effect on a stroke returns only `strokes` ένα εφέ σε περίγραμμα επιστρέφει μόνο `strokes`
|
||||
@msg an object must be a JSON object ένα αντικείμενο πρέπει να είναι αντικείμενο JSON
|
||||
@msg bad range μη έγκυρο εύρος
|
||||
@msg choices must be 1-64 non-empty, printable strings οι επιλογές πρέπει να είναι 1-64 μη κενές, εκτυπώσιμες συμβολοσειρές
|
||||
@msg coordinate out of range (limit ±{MAX_COORD} pt) συντεταγμένη εκτός εύρους (όριο ±{MAX_COORD} pt)
|
||||
@msg default is not one of the options η προεπιλογή δεν είναι μία από τις επιλογές
|
||||
@msg each piece needs a `path` κάθε κομμάτι χρειάζεται `path`
|
||||
@msg is listed twice αναφέρεται δύο φορές
|
||||
@msg pattern transform out of range μετασχηματισμός μοτίβου εκτός εύρους
|
||||
@msg read-only field '{field}' changed το πεδίο μόνο για ανάγνωση '{field}' άλλαξε
|
||||
@msg stops must be in order of `offset` τα σημεία χρώματος πρέπει να είναι ταξινομημένα κατά `offset`
|
||||
@msg the document has no pattern {pattern} το έγγραφο δεν έχει μοτίβο {pattern}
|
||||
@msg the document has no swatch {n} το έγγραφο δεν έχει δείγμα χρώματος {n}
|
||||
@msg the output η έξοδος
|
||||
@msg the path has no anchor points η διαδρομή δεν έχει σημεία αγκύρωσης
|
||||
@msg too many anchor points (limit {MAX_ANCHORS}) πάρα πολλά σημεία αγκύρωσης (όριο {MAX_ANCHORS})
|
||||
@msg true or false true ή false
|
||||
@msg unknown key `{k}`; the keys here are {_1} άγνωστο κλειδί `{k}`· τα κλειδιά εδώ είναι {_1}
|
||||
@msg {_1} is listed twice το {_1} αναφέρεται δύο φορές
|
||||
@msg {_1} is not the id of an input object το {_1} δεν είναι αναγνωριστικό αντικειμένου εισόδου
|
||||
@msg {_1} is read-only το {_1} είναι μόνο για ανάγνωση
|
||||
@msg {what} must be from {lo} to {hi} το {what} πρέπει να είναι από {lo} έως {hi}
|
||||
@msg a color component συνιστώσα χρώματος
|
||||
@msg a plate πλάκα εκτύπωσης
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Να εγκατασταθεί το «{name}», που ζητά αυτά τα δικαιώματα;
|
||||
ID: {id} Αναγνωριστικό: {id}
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -1689,6 +1689,7 @@ font-technology Variable Variables
|
||||
Include Hidden Layers Incluir capas ocultas
|
||||
Include Linked Files Incluir archivos vinculados
|
||||
Include Metadata Incluir metadatos
|
||||
Include Variable Data Incluir datos de variables
|
||||
Include non-printing layers Incluir capas no imprimibles
|
||||
Write pages longer than 200 in at full size in points Escribir las páginas de más de 200 in a tamaño real, en puntos
|
||||
Include SVG Code Incluir código SVG
|
||||
@@ -2938,6 +2939,7 @@ digits Malayalam Malayálam
|
||||
Roman Baseline Línea de base latina
|
||||
Roman Hanging Punctuation Puntuación colgante romana
|
||||
Korean Line Break Anywhere Salto de línea coreano en cualquier lugar
|
||||
Bunri-Kinshi Bunri-Kinshi
|
||||
East Asian Composition Settings Ajustes de composición de Asia oriental
|
||||
Korean Composition Settings Ajustes de composición coreana
|
||||
Rotate Rotar
|
||||
@@ -4810,6 +4812,7 @@ axis Both Ambos
|
||||
@msg unknown option `{path}.{k}` opción desconocida `{path}.{k}`
|
||||
@msg unknown overprints `{o}` (preserve, simulate or discard) sobreimpresiones desconocidas `{o}` (preserve, simulate o discard)
|
||||
@msg unknown panel `{raw}` panel desconocido `{raw}`
|
||||
@msg panels can't be shown in Presentation Mode los paneles no se pueden mostrar en el modo de presentación
|
||||
@msg unknown preference `{key}` preferencia desconocida `{key}`
|
||||
@msg Preferences imported. Preferencias importadas.
|
||||
@msg not a preferences file: {e} no es un archivo de preferencias: {e}
|
||||
@@ -6069,6 +6072,110 @@ axis Y Axis Eje Y
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` un efecto interactivo se ejecuta mientras se dibuja el documento, así que no puede ser `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out un plugin de exportación siempre recibe el presupuesto de una exportación: omite `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): una persona debe aprobarlo en la aplicación de escritorio, o pasa `allowHeavy: true` en una sesión iniciada con --allow-plugin-grants
|
||||
# Plug-in host messages: manifests, modules, runs and plug-in output
|
||||
@msg `name` must be 1-64 printable characters `name` debe tener 1-64 caracteres imprimibles
|
||||
@msg `version`, `description` or `author` is too long `version`, `description` o `author` es demasiado largo
|
||||
@msg `warnings` must be a list of strings `warnings` debe ser una lista de cadenas
|
||||
@msg `{k}` must be one of {_1} `{k}` debe ser uno de estos: {_1}
|
||||
@msg `{k}` must be {what} `{k}` debe ser {what}
|
||||
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un plugin de exportación declara de 1 a {MAX_EXTENSIONS} `extensions`
|
||||
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` como máximo {MAX_UNGROUPED} parámetros fuera de grupos: asigna un `group` a los demás
|
||||
@msg can't encode the input no se puede codificar la entrada
|
||||
@msg expected {"dataBase64"} se esperaba {"dataBase64"}
|
||||
@msg expected {"objects"} se esperaba {"objects"}
|
||||
@msg export `{name}`: {e} exportación del módulo `{name}`: {e}
|
||||
@msg extension {e} is listed twice la extensión {e} aparece dos veces
|
||||
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensión {e}: usa 1-16 letras minúsculas y dígitos, sin el punto
|
||||
@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID no válido {_1}: usa 1-64 caracteres de A-Z a-z 0-9 . _ -
|
||||
@msg invalid parameter name {k} nombre de parámetro no válido {k}
|
||||
@msg manifest is not UTF-8 el manifiesto no está en UTF-8
|
||||
@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) el manifiesto es demasiado largo (límite {MAX_MANIFEST_BYTES} bytes)
|
||||
@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) el manifiesto ocupa {_1} bytes (límite {MAX_MANIFEST_BYTES})
|
||||
@msg module is {_1} bytes (limit {_2}) el módulo ocupa {_1} bytes (límite {_2})
|
||||
@msg needs approval: it asks for {0}, which the user grants when installing it on its own requiere aprobación: pide {0}, que el usuario concede al instalarlo por separado
|
||||
@msg not JSON: {e} no es JSON: {e}
|
||||
@msg not a WebAssembly binary (missing \0asm header) no es un binario WebAssembly (falta el encabezado \0asm)
|
||||
@msg parameter `{k}`: {what} parámetro `{k}`: {what}
|
||||
@msg parameter `{name}`: {what} parámetro `{name}`: {what}
|
||||
@msg plug-in parameters must be a JSON object los parámetros del plugin deben ser un objeto JSON
|
||||
@msg the module must export its linear memory as `memory` el módulo debe exportar su memoria lineal como `memory`
|
||||
@msg the objects to change are no longer in the document los objetos que se iban a cambiar ya no están en el documento
|
||||
@msg the runtime panicked el entorno de ejecución falló
|
||||
@msg unexpected host trap error inesperado (trap) del host
|
||||
@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version devolvió {v}; este host implementa la versión {ABI_VERSION}
|
||||
@msg vc_alloc returned 0 (out of memory) vc_alloc devolvió 0 (sin memoria)
|
||||
@msg vc_alloc returned a block outside memory vc_alloc devolvió un bloque fuera de la memoria
|
||||
@msg vc_run returned error code {packed} vc_run devolvió el código de error {packed}
|
||||
@msg {_1} is not a file {_1} no es un archivo
|
||||
@msg {_1} is {_2} bytes (limit {_3}) {_1} ocupa {_2} bytes (límite {_3})
|
||||
@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objetos (límite {MAX_OBJECTS})
|
||||
@msg {at}: {msg} {at}: {msg}
|
||||
@msg {what} points outside memory {what} apunta fuera de la memoria
|
||||
@msg `angle` must be a number `angle` debe ser un número
|
||||
@msg `aspect` must be from 0.01 to 100 `aspect` debe estar entre 0.01 y 100
|
||||
@msg `cap` must be "butt", "round" or "square" `cap` debe ser "butt", "round" o "square"
|
||||
@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` debe ser "nonzero" o "evenodd"
|
||||
@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` debe ser una dirección https:// de {MAX_HELP_URL} bytes como máximo
|
||||
@msg `help` must be 1-{MAX_HELP} printable characters `help` debe tener 1-{MAX_HELP} caracteres imprimibles
|
||||
@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` debe ser un id de efecto de 1-{MAX_EFFECT_ID} bytes
|
||||
@msg `id` must be the id of an input object `id` debe ser el id de un objeto de entrada
|
||||
@msg `join` must be "miter", "round" or "bevel" `join` debe ser "miter", "round" o "bevel"
|
||||
@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` y `group` deben tener 1-{MAX_LABEL} caracteres imprimibles
|
||||
@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` debe ser 1-{MAX_TEXT} y el valor predeterminado no puede ser más largo
|
||||
@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` y `maxItems` deben ser 0-{MAX_LIST}, `maxItems` al menos 1, y el valor predeterminado debe tener esa cantidad
|
||||
@msg `name` must be a string `name` debe ser una cadena
|
||||
@msg `opacity` must be a number `opacity` debe ser un número
|
||||
@msg `type` must be "path" or "compound" `type` debe ser "path" o "compound"
|
||||
@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` debe tener 1-{MAX_UNIT} caracteres imprimibles
|
||||
@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` debe indicar de 1 a {MAX_CONDITION_VALUES} valores para `{other}`
|
||||
@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nombra `{other}`, que no es un parámetro declarado antes de este
|
||||
@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nombra como máximo {MAX_CONDITIONS} parámetros
|
||||
@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` toma los valores de `{other}`, de una casilla de verificación o una opción
|
||||
@msg `visible` must be true or false `visible` debe ser true o false
|
||||
@msg `{key}` must be a number from {_1} to {_2} `{key}` debe ser un número de {_1} a {_2}
|
||||
@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` debe ser una lista de {MAX_ITEMS} entradas como máximo
|
||||
@msg a color default must be a color object or a hex string such as "#3a7bd5" un color predeterminado debe ser un objeto de color o una cadena hexadecimal como "#3a7bd5"
|
||||
@msg a color object or a hex string such as "#3a7bd5" un objeto de color o una cadena hexadecimal como "#3a7bd5"
|
||||
@msg a freeform gradient holds at most {MAX_ITEMS} points and lines un degradado de forma libre contiene como máximo {MAX_ITEMS} puntos y líneas
|
||||
@msg a freeform gradient line names a point it doesn't have una línea del degradado de forma libre nombra un punto que el degradado no tiene
|
||||
@msg a gradient needs 1-{MAX_ITEMS} stops un degradado necesita 1-{MAX_ITEMS} paradas
|
||||
@msg a list of numbers una lista de números
|
||||
@msg a list of {min_items} to {max_items} numbers una lista de {min_items} a {max_items} números
|
||||
@msg a new object needs a `path` un objeto nuevo necesita un `path`
|
||||
@msg a number un número
|
||||
@msg a painted live effect returns only `objects` un efecto dinámico de pintura solo devuelve `objects`
|
||||
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` una pieza de un efecto dinámico de pintura solo tiene `path`, `fillRule`, `fills` y `strokes`
|
||||
@msg a read-only object was left out se omitió un objeto de solo lectura
|
||||
@msg a read-only object was left out: return it, as {"id"} alone or as it was given se omitió un objeto de solo lectura: devuélvalo como {"id"} solo o tal como se recibió
|
||||
@msg a string una cadena
|
||||
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un trazo solo tiene `paint`, `width`, `join`, `cap` y `miterLimit`
|
||||
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un trazo debe ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
|
||||
@msg an effect must be {"id", "params"?, "visible"?} un efecto debe ser {"id", "params"?, "visible"?}
|
||||
@msg an effect on a fill returns only `fills` un efecto en un relleno solo devuelve `fills`
|
||||
@msg an effect on a stroke returns only `strokes` un efecto en un trazo solo devuelve `strokes`
|
||||
@msg an object must be a JSON object un objeto debe ser un objeto JSON
|
||||
@msg bad range rango no válido
|
||||
@msg choices must be 1-64 non-empty, printable strings las opciones deben ser 1-64 cadenas imprimibles no vacías
|
||||
@msg coordinate out of range (limit ±{MAX_COORD} pt) coordenada fuera de rango (límite ±{MAX_COORD} pt)
|
||||
@msg default is not one of the options el valor predeterminado no es una de las opciones
|
||||
@msg each piece needs a `path` cada pieza necesita un `path`
|
||||
@msg is listed twice aparece dos veces
|
||||
@msg pattern transform out of range transformación del motivo fuera de rango
|
||||
@msg read-only field '{field}' changed cambió el campo de solo lectura '{field}'
|
||||
@msg stops must be in order of `offset` las paradas deben estar en orden de `offset`
|
||||
@msg the document has no pattern {pattern} el documento no tiene el motivo {pattern}
|
||||
@msg the document has no swatch {n} el documento no tiene la muestra {n}
|
||||
@msg the output la salida
|
||||
@msg the path has no anchor points el trazado no tiene puntos de ancla
|
||||
@msg too many anchor points (limit {MAX_ANCHORS}) demasiados puntos de ancla (límite {MAX_ANCHORS})
|
||||
@msg true or false true o false
|
||||
@msg unknown key `{k}`; the keys here are {_1} clave desconocida `{k}`; las claves aquí son {_1}
|
||||
@msg {_1} is listed twice {_1} aparece dos veces
|
||||
@msg {_1} is not the id of an input object {_1} no es el id de un objeto de entrada
|
||||
@msg {_1} is read-only {_1} es de solo lectura
|
||||
@msg {what} must be from {lo} to {hi} {what} debe estar entre {lo} y {hi}
|
||||
@msg a color component un componente de color
|
||||
@msg a plate una plancha
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? ¿Instalar “{name}”, que pide estos permisos?
|
||||
ID: {id} ID: {id}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2001,6 +2001,7 @@ digits Malayalam Malajalam
|
||||
Right-to-Left Paragraph Direction Kappaleen suunta oikealta vasemmalle
|
||||
Roman Hanging Punctuation Latinalaiset riippuvat välimerkit
|
||||
Korean Line Break Anywhere Korean rivinvaihto missä tahansa
|
||||
Bunri-Kinshi Bunri-Kinshi
|
||||
East Asian Composition Settings Itäaasialaisen ladonnan asetukset
|
||||
Korean Composition Settings Korean ladonnan asetukset
|
||||
Select a text object to edit its paragraph attributes. Valitse tekstiobjekti, jonka kappalemääritteitä haluat muokata.
|
||||
@@ -3072,6 +3073,7 @@ antiAlias Sharp Terävä
|
||||
Hide Code Piilota koodi
|
||||
Images: Kuvat:
|
||||
Include Metadata Sisällytä metatiedot
|
||||
Include Variable Data Sisällytä muuttujatiedot
|
||||
Internal CSS Sisäinen CSS
|
||||
Layer Names Tasojen nimet
|
||||
Minify Pienennä
|
||||
@@ -5018,6 +5020,7 @@ imageTrace Outline Ääriviiva
|
||||
@msg unknown option `{path}.{k}` tuntematon asetus `{path}.{k}`
|
||||
@msg unknown overprints `{o}` (preserve, simulate or discard) tuntematon päällepainatusten käsittely `{o}` (preserve, simulate tai discard)
|
||||
@msg unknown panel `{raw}` tuntematon paneeli `{raw}`
|
||||
@msg panels can't be shown in Presentation Mode paneeleja ei voi näyttää esitystilassa
|
||||
@msg unknown preference `{key}` tuntematon asetus `{key}`
|
||||
@msg Preferences imported. Asetukset tuotiin.
|
||||
@msg not a preferences file: {e} ei ole asetustiedosto: {e}
|
||||
@@ -6157,6 +6160,110 @@ axis Y Axis Y-akseli
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` live-tehoste suoritetaan asiakirjaa piirrettäessä, joten se ei voi olla `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out vientilaajennus saa aina viennin budjetin: jätä `budget` pois
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): ihmisen on hyväksyttävä se työpöytäsovelluksessa, tai anna `allowHeavy: true` istunnossa, joka on käynnistetty valitsimella --allow-plugin-grants
|
||||
# Plug-in host messages: manifests, modules, runs and plug-in output
|
||||
@msg `name` must be 1-64 printable characters `name`-kentässä on oltava 1-64 tulostettavaa merkkiä
|
||||
@msg `version`, `description` or `author` is too long `version`, `description` tai `author` on liian pitkä
|
||||
@msg `warnings` must be a list of strings `warnings` on oltava merkkijonojen luettelo
|
||||
@msg `{k}` must be one of {_1} `{k}` on oltava jokin seuraavista: {_1}
|
||||
@msg `{k}` must be {what} `{k}` on oltava {what}
|
||||
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` vientilaajennus ilmoittaa 1–{MAX_EXTENSIONS} `extensions`
|
||||
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` ryhmien ulkopuolella saa olla enintään {MAX_UNGROUPED} parametria: anna muille `group`
|
||||
@msg can't encode the input syötettä ei voi koodata
|
||||
@msg expected {"dataBase64"} odotettiin {"dataBase64"}
|
||||
@msg expected {"objects"} odotettiin {"objects"}
|
||||
@msg export `{name}`: {e} moduulin vienti `{name}`: {e}
|
||||
@msg extension {e} is listed twice tunniste {e} on luettelossa kahdesti
|
||||
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot tunniste {e}: käytä 1-16 pientä kirjainta ja numeroa ilman pistettä
|
||||
@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - virheellinen tunnus {_1}: käytä 1-64 merkkiä joukosta A-Z a-z 0-9 . _ -
|
||||
@msg invalid parameter name {k} virheellinen parametrin nimi {k}
|
||||
@msg manifest is not UTF-8 manifesti ei ole UTF-8-muodossa
|
||||
@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) manifesti on liian pitkä (raja {MAX_MANIFEST_BYTES} tavua)
|
||||
@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) manifesti on {_1} tavua (raja {MAX_MANIFEST_BYTES})
|
||||
@msg module is {_1} bytes (limit {_2}) moduuli on {_1} tavua (raja {_2})
|
||||
@msg needs approval: it asks for {0}, which the user grants when installing it on its own vaatii hyväksynnän: se pyytää {0}, jotka käyttäjä myöntää asentaessaan sen erikseen
|
||||
@msg not JSON: {e} ei JSON-muotoa: {e}
|
||||
@msg not a WebAssembly binary (missing \0asm header) ei WebAssembly-binääri (\0asm-otsake puuttuu)
|
||||
@msg parameter `{k}`: {what} parametri `{k}`: {what}
|
||||
@msg parameter `{name}`: {what} parametri `{name}`: {what}
|
||||
@msg plug-in parameters must be a JSON object laajennuksen parametrien on oltava JSON-objekti
|
||||
@msg the module must export its linear memory as `memory` moduulin on vietävä lineaarinen muistinsa nimellä `memory`
|
||||
@msg the objects to change are no longer in the document muutettavia objekteja ei enää ole asiakirjassa
|
||||
@msg the runtime panicked suoritusympäristö kaatui
|
||||
@msg unexpected host trap odottamaton keskeytys (trap) isännässä
|
||||
@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version palautti {v}; tämä isäntä toteuttaa version {ABI_VERSION}
|
||||
@msg vc_alloc returned 0 (out of memory) vc_alloc palautti 0 (muisti loppui)
|
||||
@msg vc_alloc returned a block outside memory vc_alloc palautti lohkon muistin ulkopuolelta
|
||||
@msg vc_run returned error code {packed} vc_run palautti virhekoodin {packed}
|
||||
@msg {_1} is not a file {_1} ei ole tiedosto
|
||||
@msg {_1} is {_2} bytes (limit {_3}) {_1} on {_2} tavua (raja {_3})
|
||||
@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objektia (raja {MAX_OBJECTS})
|
||||
@msg {at}: {msg} {at}: {msg}
|
||||
@msg {what} points outside memory {what} osoittaa muistin ulkopuolelle
|
||||
@msg `angle` must be a number `angle` on oltava luku
|
||||
@msg `aspect` must be from 0.01 to 100 `aspect` on oltava 0.01 ja 100 välillä
|
||||
@msg `cap` must be "butt", "round" or "square" `cap` on oltava "butt", "round" tai "square"
|
||||
@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` on oltava "nonzero" tai "evenodd"
|
||||
@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` on oltava enintään {MAX_HELP_URL} tavun https://-osoite
|
||||
@msg `help` must be 1-{MAX_HELP} printable characters `help`-kentässä on oltava 1-{MAX_HELP} tulostettavaa merkkiä
|
||||
@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` on oltava 1-{MAX_EFFECT_ID} tavun tehostetunnus
|
||||
@msg `id` must be the id of an input object `id` on oltava syöteobjektin tunnus
|
||||
@msg `join` must be "miter", "round" or "bevel" `join` on oltava "miter", "round" tai "bevel"
|
||||
@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label`- ja `group`-kentissä on oltava 1-{MAX_LABEL} tulostettavaa merkkiä
|
||||
@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` on oltava 1-{MAX_TEXT}, eikä oletusarvo saa olla pidempi
|
||||
@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` ja `maxItems` on oltava 0-{MAX_LIST}, `maxItems` vähintään 1, ja oletusarvossa on oltava niin monta kohdetta
|
||||
@msg `name` must be a string `name` on oltava merkkijono
|
||||
@msg `opacity` must be a number `opacity` on oltava luku
|
||||
@msg `type` must be "path" or "compound" `type` on oltava "path" tai "compound"
|
||||
@msg `unit` must be 1-{MAX_UNIT} printable characters `unit`-kentässä on oltava 1-{MAX_UNIT} tulostettavaa merkkiä
|
||||
@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen`-kentässä on lueteltava 1-{MAX_CONDITION_VALUES} arvoa parametrille `{other}`
|
||||
@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nimeää parametrin `{other}`, jota ei ole esitelty ennen tätä parametria
|
||||
@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nimeää enintään {MAX_CONDITIONS} parametria
|
||||
@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` ottaa parametrin `{other}` arvot, valintaruudun tai valinnan arvot
|
||||
@msg `visible` must be true or false `visible` on oltava true tai false
|
||||
@msg `{key}` must be a number from {_1} to {_2} `{key}` on oltava luku {_1} ja {_2} väliltä
|
||||
@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` on oltava enintään {MAX_ITEMS} kohteen taulukko
|
||||
@msg a color default must be a color object or a hex string such as "#3a7bd5" värin oletusarvon on oltava väriobjekti tai heksamerkkijono, kuten "#3a7bd5"
|
||||
@msg a color object or a hex string such as "#3a7bd5" väriobjekti tai heksamerkkijono, kuten "#3a7bd5"
|
||||
@msg a freeform gradient holds at most {MAX_ITEMS} points and lines vapaamuotoisessa liukuvärissä on enintään {MAX_ITEMS} pistettä ja viivaa
|
||||
@msg a freeform gradient line names a point it doesn't have vapaamuotoisen liukuvärin viiva nimeää pisteen, jota liukuvärissä ei ole
|
||||
@msg a gradient needs 1-{MAX_ITEMS} stops liukuväri tarvitsee 1-{MAX_ITEMS} väripistettä
|
||||
@msg a list of numbers lukujen luettelo
|
||||
@msg a list of {min_items} to {max_items} numbers {min_items}-{max_items} luvun luettelo
|
||||
@msg a new object needs a `path` uusi objekti tarvitsee kentän `path`
|
||||
@msg a number luku
|
||||
@msg a painted live effect returns only `objects` maalaava live-tehoste palauttaa vain kentän `objects`
|
||||
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` maalaavan live-tehosteen osalla on vain `path`, `fillRule`, `fills` ja `strokes`
|
||||
@msg a read-only object was left out vain luku -objekti jätettiin pois
|
||||
@msg a read-only object was left out: return it, as {"id"} alone or as it was given vain luku -objekti jätettiin pois: palauta se pelkkänä {"id"}-muotona tai sellaisena kuin se annettiin
|
||||
@msg a string merkkijono
|
||||
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` viivalla on vain `paint`, `width`, `join`, `cap` ja `miterLimit`
|
||||
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} viivan on oltava muotoa {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
|
||||
@msg an effect must be {"id", "params"?, "visible"?} tehosteen on oltava muotoa {"id", "params"?, "visible"?}
|
||||
@msg an effect on a fill returns only `fills` täytön tehoste palauttaa vain kentän `fills`
|
||||
@msg an effect on a stroke returns only `strokes` viivan tehoste palauttaa vain kentän `strokes`
|
||||
@msg an object must be a JSON object objektin on oltava JSON-objekti
|
||||
@msg bad range virheellinen alue
|
||||
@msg choices must be 1-64 non-empty, printable strings vaihtoehtojen on oltava 1-64 ei-tyhjää, tulostettavaa merkkijonoa
|
||||
@msg coordinate out of range (limit ±{MAX_COORD} pt) koordinaatti alueen ulkopuolella (raja ±{MAX_COORD} pt)
|
||||
@msg default is not one of the options oletusarvo ei ole mikään vaihtoehdoista
|
||||
@msg each piece needs a `path` jokainen osa tarvitsee kentän `path`
|
||||
@msg is listed twice on luettelossa kahdesti
|
||||
@msg pattern transform out of range kuvion muunnos alueen ulkopuolella
|
||||
@msg read-only field '{field}' changed vain luku -kenttä '{field}' muuttui
|
||||
@msg stops must be in order of `offset` väripisteiden on oltava `offset`-järjestyksessä
|
||||
@msg the document has no pattern {pattern} asiakirjassa ei ole kuviota {pattern}
|
||||
@msg the document has no swatch {n} asiakirjassa ei ole värimallia {n}
|
||||
@msg the output tuloste
|
||||
@msg the path has no anchor points polulla ei ole ankkuripisteitä
|
||||
@msg too many anchor points (limit {MAX_ANCHORS}) liian monta ankkuripistettä (raja {MAX_ANCHORS})
|
||||
@msg true or false true tai false
|
||||
@msg unknown key `{k}`; the keys here are {_1} tuntematon avain `{k}`; tässä käytettävät avaimet ovat {_1}
|
||||
@msg {_1} is listed twice {_1} on luettelossa kahdesti
|
||||
@msg {_1} is not the id of an input object {_1} ei ole syöteobjektin tunnus
|
||||
@msg {_1} is read-only {_1} on vain luku -tilassa
|
||||
@msg {what} must be from {lo} to {hi} {what}: arvon on oltava {lo} ja {hi} välillä
|
||||
@msg a color component värikomponentti
|
||||
@msg a plate painolevy
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Asennetaanko ”{name}”, joka pyytää näitä käyttöoikeuksia?
|
||||
ID: {id} Tunnus: {id}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1683,6 +1683,7 @@ font-technology Variable Variables
|
||||
Include Hidden Layers Inclure les calques masqués
|
||||
Include Linked Files Inclure les fichiers liés
|
||||
Include Metadata Inclure les métadonnées
|
||||
Include Variable Data Inclure les données des variables
|
||||
Include non-printing layers Inclure les calques non imprimables
|
||||
Write pages longer than 200 in at full size in points Écrire les pages de plus de 200 in en taille réelle, en points
|
||||
Include SVG Code Inclure le code SVG
|
||||
@@ -2930,6 +2931,7 @@ digits Malayalam Malayalam
|
||||
Roman Baseline Ligne de base romaine
|
||||
Roman Hanging Punctuation Ponctuation romaine hors justification
|
||||
Korean Line Break Anywhere Saut de ligne coréen n’importe où
|
||||
Bunri-Kinshi Bunri-Kinshi
|
||||
East Asian Composition Settings Paramètres de composition d’Asie orientale
|
||||
Korean Composition Settings Paramètres de composition coréenne
|
||||
Rotate Rotation
|
||||
@@ -4799,6 +4801,7 @@ axis Both Les deux
|
||||
@msg unknown option `{path}.{k}` option inconnue `{path}.{k}`
|
||||
@msg unknown overprints `{o}` (preserve, simulate or discard) surimpressions inconnues `{o}` (preserve, simulate ou discard)
|
||||
@msg unknown panel `{raw}` panneau inconnu `{raw}`
|
||||
@msg panels can't be shown in Presentation Mode les panneaux ne peuvent pas être affichés en mode Présentation
|
||||
@msg unknown preference `{key}` préférence inconnue `{key}`
|
||||
@msg Preferences imported. Préférences importées.
|
||||
@msg not a preferences file: {e} ce n’est pas un fichier de préférences : {e}
|
||||
@@ -6122,6 +6125,110 @@ axis Y Axis Axe Y
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` un effet dynamique s’exécute pendant le tracé du document, il ne peut donc pas être `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : une personne doit l’approuver dans l’application de bureau, ou passez `allowHeavy: true` dans une session démarrée avec --allow-plugin-grants
|
||||
# Plug-in host messages: manifests, modules, runs and plug-in output
|
||||
@msg `name` must be 1-64 printable characters `name` doit compter 1-64 caractères imprimables
|
||||
@msg `version`, `description` or `author` is too long `version`, `description` ou `author` est trop long
|
||||
@msg `warnings` must be a list of strings `warnings` doit être une liste de chaînes
|
||||
@msg `{k}` must be one of {_1} `{k}` doit valoir l’une de ces valeurs : {_1}
|
||||
@msg `{k}` must be {what} `{k}` doit être {what}
|
||||
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un module externe d’exportation déclare de 1 à {MAX_EXTENSIONS} `extensions`
|
||||
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` {MAX_UNGROUPED} paramètres au plus hors des groupes : donnez un `group` aux autres
|
||||
@msg can't encode the input impossible d’encoder l’entrée
|
||||
@msg expected {"dataBase64"} attendu : {"dataBase64"}
|
||||
@msg expected {"objects"} attendu : {"objects"}
|
||||
@msg export `{name}`: {e} export du module `{name}` : {e}
|
||||
@msg extension {e} is listed twice l’extension {e} figure deux fois
|
||||
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extension {e} : utilisez 1-16 lettres minuscules et chiffres, sans le point
|
||||
@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - ID non valide {_1} : utilisez 1-64 caractères parmi A-Z a-z 0-9 . _ -
|
||||
@msg invalid parameter name {k} nom de paramètre non valide {k}
|
||||
@msg manifest is not UTF-8 le manifeste n’est pas en UTF-8
|
||||
@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) le manifeste est trop long (limite {MAX_MANIFEST_BYTES} octets)
|
||||
@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) le manifeste fait {_1} octets (limite {MAX_MANIFEST_BYTES})
|
||||
@msg module is {_1} bytes (limit {_2}) le module fait {_1} octets (limite {_2})
|
||||
@msg needs approval: it asks for {0}, which the user grants when installing it on its own approbation requise : il demande {0}, que l’utilisateur accorde en l’installant seul
|
||||
@msg not JSON: {e} pas du JSON : {e}
|
||||
@msg not a WebAssembly binary (missing \0asm header) pas un binaire WebAssembly (en-tête \0asm absent)
|
||||
@msg parameter `{k}`: {what} paramètre `{k}` : {what}
|
||||
@msg parameter `{name}`: {what} paramètre `{name}` : {what}
|
||||
@msg plug-in parameters must be a JSON object les paramètres du plug-in doivent être un objet JSON
|
||||
@msg the module must export its linear memory as `memory` le module doit exporter sa mémoire linéaire sous le nom `memory`
|
||||
@msg the objects to change are no longer in the document les objets à modifier ne sont plus dans le document
|
||||
@msg the runtime panicked l’environnement d’exécution a planté
|
||||
@msg unexpected host trap arrêt inattendu (trap) de l’hôte
|
||||
@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version a renvoyé {v} ; cet hôte implémente la version {ABI_VERSION}
|
||||
@msg vc_alloc returned 0 (out of memory) vc_alloc a renvoyé 0 (mémoire épuisée)
|
||||
@msg vc_alloc returned a block outside memory vc_alloc a renvoyé un bloc hors de la mémoire
|
||||
@msg vc_run returned error code {packed} vc_run a renvoyé le code d’erreur {packed}
|
||||
@msg {_1} is not a file {_1} n’est pas un fichier
|
||||
@msg {_1} is {_2} bytes (limit {_3}) {_1} fait {_2} octets (limite {_3})
|
||||
@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objets (limite {MAX_OBJECTS})
|
||||
@msg {at}: {msg} {at} : {msg}
|
||||
@msg {what} points outside memory {what} pointe hors de la mémoire
|
||||
@msg `angle` must be a number `angle` doit être un nombre
|
||||
@msg `aspect` must be from 0.01 to 100 `aspect` doit être compris entre 0.01 et 100
|
||||
@msg `cap` must be "butt", "round" or "square" `cap` doit valoir "butt", "round" ou "square"
|
||||
@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` doit valoir "nonzero" ou "evenodd"
|
||||
@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` doit être une adresse https:// d’au plus {MAX_HELP_URL} octets
|
||||
@msg `help` must be 1-{MAX_HELP} printable characters `help` doit compter 1-{MAX_HELP} caractères imprimables
|
||||
@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` doit être un identifiant d’effet de 1-{MAX_EFFECT_ID} octets
|
||||
@msg `id` must be the id of an input object `id` doit être l’identifiant d’un objet d’entrée
|
||||
@msg `join` must be "miter", "round" or "bevel" `join` doit valoir "miter", "round" ou "bevel"
|
||||
@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` et `group` doivent compter 1-{MAX_LABEL} caractères imprimables
|
||||
@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` doit valoir 1-{MAX_TEXT}, et la valeur par défaut ne doit pas être plus longue
|
||||
@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` et `maxItems` doivent valoir 0-{MAX_LIST}, `maxItems` au moins 1, et la valeur par défaut doit en compter autant
|
||||
@msg `name` must be a string `name` doit être une chaîne
|
||||
@msg `opacity` must be a number `opacity` doit être un nombre
|
||||
@msg `type` must be "path" or "compound" `type` doit valoir "path" ou "compound"
|
||||
@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` doit compter 1-{MAX_UNIT} caractères imprimables
|
||||
@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` doit indiquer 1 à {MAX_CONDITION_VALUES} valeurs pour `{other}`
|
||||
@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` nomme `{other}`, qui n’est pas un paramètre déclaré avant celui-ci
|
||||
@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` nomme au plus {MAX_CONDITIONS} paramètres
|
||||
@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` prend les valeurs de `{other}`, celles d’une case à cocher ou d’un choix
|
||||
@msg `visible` must be true or false `visible` doit valoir true ou false
|
||||
@msg `{key}` must be a number from {_1} to {_2} `{key}` doit être un nombre de {_1} à {_2}
|
||||
@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` doit être un tableau d’au plus {MAX_ITEMS} entrées
|
||||
@msg a color default must be a color object or a hex string such as "#3a7bd5" une couleur par défaut doit être un objet couleur ou une chaîne hexadécimale comme "#3a7bd5"
|
||||
@msg a color object or a hex string such as "#3a7bd5" un objet couleur ou une chaîne hexadécimale comme "#3a7bd5"
|
||||
@msg a freeform gradient holds at most {MAX_ITEMS} points and lines un dégradé de forme libre contient au plus {MAX_ITEMS} points et lignes
|
||||
@msg a freeform gradient line names a point it doesn't have une ligne du dégradé de forme libre nomme un point que le dégradé n’a pas
|
||||
@msg a gradient needs 1-{MAX_ITEMS} stops un dégradé nécessite 1-{MAX_ITEMS} étapes
|
||||
@msg a list of numbers une liste de nombres
|
||||
@msg a list of {min_items} to {max_items} numbers une liste de {min_items} à {max_items} nombres
|
||||
@msg a new object needs a `path` un nouvel objet nécessite un `path`
|
||||
@msg a number un nombre
|
||||
@msg a painted live effect returns only `objects` un effet dynamique de peinture renvoie seulement `objects`
|
||||
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` une pièce d’un effet dynamique de peinture a seulement `path`, `fillRule`, `fills` et `strokes`
|
||||
@msg a read-only object was left out un objet en lecture seule a été omis
|
||||
@msg a read-only object was left out: return it, as {"id"} alone or as it was given un objet en lecture seule a été omis : renvoyez-le, sous la forme {"id"} seul ou tel qu’il a été fourni
|
||||
@msg a string une chaîne
|
||||
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un contour a seulement `paint`, `width`, `join`, `cap` et `miterLimit`
|
||||
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un contour doit être {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
|
||||
@msg an effect must be {"id", "params"?, "visible"?} un effet doit être {"id", "params"?, "visible"?}
|
||||
@msg an effect on a fill returns only `fills` un effet sur un fond renvoie seulement `fills`
|
||||
@msg an effect on a stroke returns only `strokes` un effet sur un contour renvoie seulement `strokes`
|
||||
@msg an object must be a JSON object un objet doit être un objet JSON
|
||||
@msg bad range plage non valide
|
||||
@msg choices must be 1-64 non-empty, printable strings les choix doivent être 1-64 chaînes imprimables non vides
|
||||
@msg coordinate out of range (limit ±{MAX_COORD} pt) coordonnée hors limites (limite ±{MAX_COORD} pt)
|
||||
@msg default is not one of the options la valeur par défaut ne fait pas partie des options
|
||||
@msg each piece needs a `path` chaque pièce nécessite un `path`
|
||||
@msg is listed twice figure deux fois
|
||||
@msg pattern transform out of range transformation du motif hors limites
|
||||
@msg read-only field '{field}' changed le champ en lecture seule '{field}' a été modifié
|
||||
@msg stops must be in order of `offset` les étapes doivent être classées par `offset`
|
||||
@msg the document has no pattern {pattern} le document n’a pas de motif {pattern}
|
||||
@msg the document has no swatch {n} le document n’a pas de nuance {n}
|
||||
@msg the output la sortie
|
||||
@msg the path has no anchor points le tracé n’a aucun point d’ancrage
|
||||
@msg too many anchor points (limit {MAX_ANCHORS}) trop de points d’ancrage (limite {MAX_ANCHORS})
|
||||
@msg true or false true ou false
|
||||
@msg unknown key `{k}`; the keys here are {_1} clé inconnue `{k}` ; les clés ici sont {_1}
|
||||
@msg {_1} is listed twice {_1} figure deux fois
|
||||
@msg {_1} is not the id of an input object {_1} n’est pas l’identifiant d’un objet d’entrée
|
||||
@msg {_1} is read-only {_1} est en lecture seule
|
||||
@msg {what} must be from {lo} to {hi} {what} doit être compris entre {lo} et {hi}
|
||||
@msg a color component une composante de couleur
|
||||
@msg a plate une plaque
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ?
|
||||
ID: {id} ID : {id}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
+104
-34
@@ -2328,6 +2328,7 @@ antiAlias Sharp शार्प
|
||||
Include in playback प्लेबैक में शामिल करें
|
||||
Include Linked Files लिंक्ड फ़ाइलें शामिल करें
|
||||
Include Metadata मेटाडाटा शामिल
|
||||
Include Variable Data वेरिएबल डेटा शामिल
|
||||
Include non-printing layers गैर-प्रिंटिंग लेयर्स को शामिल करें
|
||||
Write pages longer than 200 in at full size in points 200 in से लंबे पेज पूरे आकार में, पॉइंट में लिखें
|
||||
Include Objects On Canvas कैनवास पर ऑब्जेक्ट्स शामिल करें
|
||||
@@ -4008,6 +4009,7 @@ antiAlias Sharp शार्प
|
||||
Roman Baseline रोमन बेसलाइन
|
||||
Roman Hanging Punctuation रोमन हैंगिंग विराम चिह्न
|
||||
Korean Line Break Anywhere कोरियाई लाइन ब्रेक कहीं भी
|
||||
Bunri-Kinshi बुनरी-किंशी
|
||||
East Asian Composition Settings पूर्वी एशियाई संरचना सेटिंग्स
|
||||
Korean Composition Settings कोरियाई संरचना सेटिंग्स
|
||||
Rotate घुमाएँ
|
||||
@@ -5446,6 +5448,73 @@ antiAlias Sharp शार्प
|
||||
@id pathfinder.add मिलाएँ
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” अधिक संसाधन माँगता है (हर रन में {minutes} मिनट और सामान्य से {times} गुना निर्देश): इसे डेस्कटॉप ऐप में किसी व्यक्ति को स्वीकृत करना होगा, या --allow-plugin-grants से शुरू किए गए सत्र में `allowHeavy: true` दें
|
||||
@msg "{0}" is not a colour: write it as #rrggbb "{0}" एक रंग नहीं है: इसे #rrggbb के रूप में लिखें
|
||||
# Plug-in host messages: manifests, modules, runs and plug-in output
|
||||
@msg needs approval: it asks for {0}, which the user grants when installing it on its own स्वीकृति आवश्यक: यह {0} माँगता है, जिसे उपयोगकर्ता इसे अलग से स्थापित करते समय देता है
|
||||
@msg `angle` must be a number `angle` एक संख्या होना चाहिए
|
||||
@msg `aspect` must be from 0.01 to 100 `aspect` 0.01 से 100 तक होना चाहिए
|
||||
@msg `cap` must be "butt", "round" or "square" `cap` "butt", "round" या "square" होना चाहिए
|
||||
@msg `fillRule` must be "nonzero" or "evenodd" `fillRule` "nonzero" या "evenodd" होना चाहिए
|
||||
@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` अधिकतम {MAX_HELP_URL} बाइट का https:// पता होना चाहिए
|
||||
@msg `help` must be 1-{MAX_HELP} printable characters `help` में 1-{MAX_HELP} प्रिंट करने योग्य वर्ण होने चाहिए
|
||||
@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id` 1-{MAX_EFFECT_ID} बाइट का इफ़ेक्ट id होना चाहिए
|
||||
@msg `id` must be the id of an input object `id` किसी इनपुट ऑब्जेक्ट का id होना चाहिए
|
||||
@msg `join` must be "miter", "round" or "bevel" `join` "miter", "round" या "bevel" होना चाहिए
|
||||
@msg `label` and `group` must be 1-{MAX_LABEL} printable characters `label` और `group` में 1-{MAX_LABEL} प्रिंट करने योग्य वर्ण होने चाहिए
|
||||
@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer `maxLength` 1-{MAX_TEXT} होना चाहिए, और डिफ़ॉल्ट उससे लंबा नहीं होना चाहिए
|
||||
@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many `minItems` और `maxItems` 0-{MAX_LIST} होने चाहिए, `maxItems` कम से कम 1, और डिफ़ॉल्ट में उतने ही आइटम होने चाहिए
|
||||
@msg `name` must be a string `name` एक स्ट्रिंग होना चाहिए
|
||||
@msg `opacity` must be a number `opacity` एक संख्या होना चाहिए
|
||||
@msg `type` must be "path" or "compound" `type` "path" या "compound" होना चाहिए
|
||||
@msg `unit` must be 1-{MAX_UNIT} printable characters `unit` में 1-{MAX_UNIT} प्रिंट करने योग्य वर्ण होने चाहिए
|
||||
@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` `visibleWhen` में `{other}` के लिए 1 से {MAX_CONDITION_VALUES} मान होने चाहिए
|
||||
@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one `visibleWhen` में `{other}` दिया गया है, जो इससे पहले घोषित पैरामीटर नहीं है
|
||||
@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters `visibleWhen` में अधिकतम {MAX_CONDITIONS} पैरामीटर हो सकते हैं
|
||||
@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice `visibleWhen` `{other}` के मान लेता है, जो चेकबॉक्स या विकल्प होना चाहिए
|
||||
@msg `visible` must be true or false `visible` true या false होना चाहिए
|
||||
@msg `{key}` must be a number from {_1} to {_2} `{key}` {_1} से {_2} तक की संख्या होना चाहिए
|
||||
@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}` अधिकतम {MAX_ITEMS} प्रविष्टियों का ऐरे होना चाहिए
|
||||
@msg a color default must be a color object or a hex string such as "#3a7bd5" रंग का डिफ़ॉल्ट एक रंग ऑब्जेक्ट या "#3a7bd5" जैसी हेक्स स्ट्रिंग होना चाहिए
|
||||
@msg a color object or a hex string such as "#3a7bd5" एक रंग ऑब्जेक्ट या "#3a7bd5" जैसी हेक्स स्ट्रिंग
|
||||
@msg a freeform gradient holds at most {MAX_ITEMS} points and lines फ्रीफॉर्म ग्रेडिएंट में अधिकतम {MAX_ITEMS} पॉइंट और लाइनें होती हैं
|
||||
@msg a freeform gradient line names a point it doesn't have फ्रीफॉर्म ग्रेडिएंट की एक लाइन ऐसे पॉइंट का नाम देती है जो ग्रेडिएंट में नहीं है
|
||||
@msg a gradient needs 1-{MAX_ITEMS} stops ग्रेडिएंट को 1-{MAX_ITEMS} स्टॉप चाहिए
|
||||
@msg a list of numbers संख्याओं की एक सूची
|
||||
@msg a list of {min_items} to {max_items} numbers {min_items} से {max_items} संख्याओं की एक सूची
|
||||
@msg a new object needs a `path` नए ऑब्जेक्ट को `path` चाहिए
|
||||
@msg a number एक संख्या
|
||||
@msg a painted live effect returns only `objects` पेंट करने वाला लाइव इफ़ेक्ट केवल `objects` लौटाता है
|
||||
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` पेंट करने वाले लाइव इफ़ेक्ट के हिस्से में केवल `path`, `fillRule`, `fills` और `strokes` होते हैं
|
||||
@msg a read-only object was left out एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया
|
||||
@msg a read-only object was left out: return it, as {"id"} alone or as it was given एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया: इसे केवल {"id"} के रूप में या जैसा दिया गया था वैसा लौटाएँ
|
||||
@msg a string एक स्ट्रिंग
|
||||
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` स्ट्रोक में केवल `paint`, `width`, `join`, `cap` और `miterLimit` होते हैं
|
||||
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} स्ट्रोक {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} होना चाहिए
|
||||
@msg an effect must be {"id", "params"?, "visible"?} इफ़ेक्ट {"id", "params"?, "visible"?} होना चाहिए
|
||||
@msg an effect on a fill returns only `fills` फ़िल पर इफ़ेक्ट केवल `fills` लौटाता है
|
||||
@msg an effect on a stroke returns only `strokes` स्ट्रोक पर इफ़ेक्ट केवल `strokes` लौटाता है
|
||||
@msg an object must be a JSON object ऑब्जेक्ट एक JSON ऑब्जेक्ट होना चाहिए
|
||||
@msg bad range अमान्य सीमा
|
||||
@msg choices must be 1-64 non-empty, printable strings विकल्प 1-64 खाली नहीं, प्रिंट करने योग्य स्ट्रिंग होने चाहिए
|
||||
@msg coordinate out of range (limit ±{MAX_COORD} pt) निर्देशांक सीमा से बाहर है (सीमा ±{MAX_COORD} pt)
|
||||
@msg default is not one of the options डिफ़ॉल्ट विकल्पों में से कोई नहीं है
|
||||
@msg each piece needs a `path` हर हिस्से को `path` चाहिए
|
||||
@msg is listed twice दो बार सूचीबद्ध है
|
||||
@msg pattern transform out of range पैटर्न ट्रांसफ़ॉर्म सीमा से बाहर है
|
||||
@msg read-only field '{field}' changed केवल-पढ़ने योग्य फ़ील्ड '{field}' बदल गया
|
||||
@msg stops must be in order of `offset` स्टॉप `offset` के क्रम में होने चाहिए
|
||||
@msg the document has no pattern {pattern} डॉक्यूमेंट में पैटर्न {pattern} नहीं है
|
||||
@msg the document has no swatch {n} डॉक्यूमेंट में स्वॉच {n} नहीं है
|
||||
@msg the output आउटपुट
|
||||
@msg the path has no anchor points पाथ में कोई एंकर पॉइंट नहीं है
|
||||
@msg too many anchor points (limit {MAX_ANCHORS}) बहुत अधिक एंकर पॉइंट (सीमा {MAX_ANCHORS})
|
||||
@msg true or false true या false
|
||||
@msg unknown key `{k}`; the keys here are {_1} अज्ञात कुंजी `{k}`; यहाँ की कुंजियाँ {_1} हैं
|
||||
@msg {_1} is listed twice {_1} दो बार सूचीबद्ध है
|
||||
@msg {_1} is not the id of an input object {_1} किसी इनपुट ऑब्जेक्ट का id नहीं है
|
||||
@msg {_1} is read-only {_1} केवल-पढ़ने योग्य है
|
||||
@msg {what} must be from {lo} to {hi} {what} {lo} से {hi} तक होना चाहिए
|
||||
@msg a color component एक रंग घटक
|
||||
@msg a plate एक प्लेट
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? "{name}" स्थापित करें, जो ये अनुमतियां मांगता है?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5542,7 +5611,7 @@ antiAlias Sharp शार्प
|
||||
@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} समाप्त हो गया, लेकिन डॉक्यूमेंट या प्लग-इन इस बीच बदल गया: इसे फिर से चलाएं
|
||||
@msg {_1} font families available {_1} फ़ॉन्ट परिवारों उपलब्ध
|
||||
@msg {_1} has no {_2} style {_1} में कोई {_2} स्टाइल नहीं है
|
||||
@msg {_1} is {_2} bytes (limit {_3}) {_1} {_2} बाइट्स (limit {_3}) है।
|
||||
@msg {_1} is {_2} bytes (limit {_3}) {_1} {_2} बाइट्स का है (सीमा {_3})
|
||||
@msg {_1} is a live effect (Effect › Plug-ins) {_1} एक लाइव इफ़ेक्ट (इफ़ेक्टी> प्लग-इन) है।
|
||||
@msg {_1} is already used by {_2} {_1} का उपयोग पहले से ही {_2} द्वारा किया जाता है
|
||||
@msg {_1} is an exporter (File › Export › Export As…) {_1} एक निर्यातक (फ़ाइल> निर्यात › निर्यात As...) है।
|
||||
@@ -5551,7 +5620,7 @@ antiAlias Sharp शार्प
|
||||
@msg {_1} isn't a path {_1} एक पाथ नहीं है
|
||||
@msg {_1} MB of document, at most {_2} MB {_1} MB of document, at most {_2} MB
|
||||
@msg {_1} MB of images, at most {_2} MB {_1} MB of images, at most {_2} MB
|
||||
@msg {_1} objects (limit {MAX_OBJECTS}) {_1} ऑब्जेक्ट्स (limit {MAX_OBJECTS})
|
||||
@msg {_1} objects (limit {MAX_OBJECTS}) {_1} ऑब्जेक्ट्स (सीमा {MAX_OBJECTS})
|
||||
@msg {_1} options: {e} {_1} ऑप्शन्स: {e}
|
||||
@msg {_1} paints nothing to rasterize {_1} कुछ भी नहीं पेंट करता है
|
||||
@msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) {_1} पासवर्ड के पास {MAX_LEGACY_PASSWORD} ASCII वर्ण (PDF 1.7) और बाद में कोई लेना) हो सकता है।
|
||||
@@ -5655,7 +5724,7 @@ antiAlias Sharp शार्प
|
||||
@msg {v} is not an asset id {v} एक परिसंपत्ति आईडी नहीं है
|
||||
@msg {what} can't be intertwined {what} अंतर नहीं किया जा सकता है
|
||||
@msg {what} doesn't fit the paper with its bleed and marks {what} अपने ब्लेड और मार्क्स के साथ पेपर को फिट नहीं करता है
|
||||
@msg {what} points outside memory {what} स्मृति के बाहर अंक
|
||||
@msg {what} points outside memory {what} मेमोरी के बाहर इंगित करता है
|
||||
@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} {MAX_TILES} टाइल्स से अधिक प्रिंट करेगा: इसे नीचे स्केल करें
|
||||
@msg `{_1}` can't be edited or removed `{_1}` को संपादित या हटाया नहीं जा सकता है
|
||||
@msg `{_1}` has no brushes to apply `{_1}` को लागू करने के लिए कोई ब्रश नहीं है
|
||||
@@ -5685,13 +5754,13 @@ antiAlias Sharp शार्प
|
||||
@msg `{k}` is true or false `{k}` true या false है
|
||||
@msg `{k}` isn't an export setting: {_1} `{k}` एक निर्यात सेटिंग नहीं है: {_1}
|
||||
@msg `{k}` must be [x, y] `{k}` होना चाहिए [x, y]
|
||||
@msg `{k}` must be {what} `{k}` होना चाहिए {what}
|
||||
@msg `{k}` must be {what} `{k}` {what} होना चाहिए
|
||||
@msg `{k}` must be {what}, not {v} `{k}` होना चाहिए {what}, नहीं {v}
|
||||
@msg `{k}` must be a byte offset `{k}` को एक बाइट ऑफ़सेट होना चाहिए
|
||||
@msg `{k}` must be a number `{k}` होना चाहिए
|
||||
@msg `{k}` must be a number `{k}` एक संख्या होना चाहिए
|
||||
@msg `{k}` must be a number of points `{k}` को कई अंक होना चाहिए
|
||||
@msg `{k}` must be a string `{k}` एक स्ट्रिंग होना चाहिए
|
||||
@msg `{k}` must be a whole number `{k}` होना चाहिए
|
||||
@msg `{k}` must be a whole number `{k}` एक पूर्ण संख्या होना चाहिए
|
||||
@msg `{k}` must be from {lo} to {hi} for {key} `{k}` होना चाहिए {lo} से {hi} {key} के लिए
|
||||
@msg `{k}` must be one of {_1} `{k}` को {_1} में से एक होना चाहिए
|
||||
@msg `{keep}` is not a side of this axis `{keep}` इस अक्ष का एक पक्ष नहीं है
|
||||
@@ -5709,13 +5778,13 @@ antiAlias Sharp शार्प
|
||||
@msg `{key}` must be a #rrggbb color `{key}` एक #rrggbb रंग होना चाहिए
|
||||
@msg `{key}` must be a #rrggbb colour `{key}` एक #rrggbb रंग होना चाहिए
|
||||
@msg `{key}` must be a non-negative integer `{key}` को एक गैर-नकारात्मक पूर्णांक होना चाहिए
|
||||
@msg `{key}` must be a number `{key}` होना चाहिए
|
||||
@msg `{key}` must be a number {_1}..{_2} `{key}` होना चाहिए a number {_1}.{_2}
|
||||
@msg `{key}` must be a number not below 0 `{key}` को 0 से नीचे नहीं होना चाहिए
|
||||
@msg `{key}` must be a number, not {v} `{key}` एक नंबर होना चाहिए, नहीं {v}
|
||||
@msg `{key}` must be a number `{key}` एक संख्या होना चाहिए
|
||||
@msg `{key}` must be a number {_1}..{_2} `{key}` {_1}..{_2} की एक संख्या होना चाहिए
|
||||
@msg `{key}` must be a number not below 0 `{key}` 0 से कम नहीं होने वाली संख्या होना चाहिए
|
||||
@msg `{key}` must be a number, not {v} `{key}` एक संख्या होना चाहिए, {v} नहीं
|
||||
@msg `{key}` must be a profile name or null `{key}` को प्रोफ़ाइल नाम या null होना चाहिए
|
||||
@msg `{key}` must be a string `{key}` एक स्ट्रिंग होना चाहिए
|
||||
@msg `{key}` must be a whole number `{key}` होना चाहिए
|
||||
@msg `{key}` must be a whole number `{key}` एक पूर्ण संख्या होना चाहिए
|
||||
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` को {min} और {max} ({x}) के बीच पूरी संख्या होना चाहिए।
|
||||
@msg `{key}` must be a whole number of at least 0, not {n} `{key}` को कम से कम 0 की पूरी संख्या होना चाहिए, नहीं {n}
|
||||
@msg `{key}` must be an appearance item index (paint order) or null `{key}` एक उपस्थिति आइटम सूचकांक (पेंट ऑर्डर) या null होना चाहिए
|
||||
@@ -5926,7 +5995,7 @@ antiAlias Sharp शार्प
|
||||
@msg `mode` must be one of {_1} `mode` को {_1} में से एक होना चाहिए
|
||||
@msg `mojikumi` must be "none" or "lineEndHalf" `mojikumi` होना चाहिए "none" or "lineEndHalf"
|
||||
@msg `name` is empty `name` खाली है
|
||||
@msg `name` must be 1-64 printable characters `name` को 1-64 प्रिंट करने योग्य वर्ण होना चाहिए
|
||||
@msg `name` must be 1-64 printable characters `name` में 1-64 प्रिंट करने योग्य वर्ण होने चाहिए
|
||||
@msg `names` is a list of preset names `names` प्रीसेट नामों की सूची है
|
||||
@msg `names` lists preset names `names` प्रीसेट नामों की सूची देता है
|
||||
@msg `names` must be a list of names `names` को नामों की सूची होना चाहिए
|
||||
@@ -6133,7 +6202,7 @@ antiAlias Sharp शार्प
|
||||
@msg an artboard is listed twice एक आर्टबोर्ड को दो बार सूचीबद्ध किया गया है
|
||||
@msg an empty artboard has no width or height to scale to एक खाली आर्टबोर्ड में स्केल करने के लिए कोई चौड़ाई या ऊंचाई नहीं है
|
||||
@msg an exporter always gets an export's budget: leave `budget` out एक निर्यातक हमेशा एक निर्यात बजट हो जाता है: `budget` बाहर छोड़ दें
|
||||
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` एक निर्यातक ने 1 को {MAX_EXTENSIONS} `extensions` को घोषित किया
|
||||
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` एक्सपोर्टर 1 से {MAX_EXTENSIONS} `extensions` घोषित करता है
|
||||
@msg an image has no pixels and was left out एक इमेज में कोई पिक्सेल नहीं है और इसे छोड़ दिया गया था
|
||||
@msg an image is too large for DXF and was left out DXF के लिए एक इमेज बहुत बड़ी है और इसे छोड़ दिया गया था
|
||||
@msg an image of {_1} MB, at most {_2} MB {_1} MB की एक इमेज, अधिकांश {_2} MB पर
|
||||
@@ -6187,7 +6256,7 @@ antiAlias Sharp शार्प
|
||||
@msg at most {MAX_MATCHES} matches are kept अधिकांश {MAX_MATCHES} मैचों को रखा जाता है
|
||||
@msg at most {MAX_POINTS} points {MAX_POINTS} पॉइंट पर
|
||||
@msg at most {MAX_QUEUE} files सबसे अधिक {MAX_QUEUE} फ़ाइलों में
|
||||
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` अधिकांश {MAX_UNGROUPED} मापदंडों पर समूहों के बाहर: दूसरों को `group` देना
|
||||
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` समूहों के बाहर अधिकतम {MAX_UNGROUPED} पैरामीटर: बाकी को एक `group` दें
|
||||
@msg at must be [x, y] at: [x, y] होना चाहिए
|
||||
@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard इस पैमाने पर ड्राइंग {w} × {h} pt है, कैनवास से अधिक रखती है: एक छोटे पैमाने सिलेक्ट करें या इसे आर्टबोर्ड पर फिट करें
|
||||
@msg at/rect lie outside the canvas कैनवास के बाहर / सही झूठ
|
||||
@@ -6264,7 +6333,7 @@ antiAlias Sharp शार्प
|
||||
@msg can't add the editing data for other apps: {why} अन्य ऐप्स के लिए संपादन डेटा नहीं जोड़ सकते: {why}
|
||||
@msg can't edit the written PDF: {why} लिखित PDF संपादित नहीं कर सकता: {why}
|
||||
@msg can't encode the document: {0} डॉक्यूमेंट को एनकोड नहीं कर सकता: {0}
|
||||
@msg can't encode the input इनपुट को एनकोड नहीं कर सकता
|
||||
@msg can't encode the input इनपुट एनकोड नहीं किया जा सकता
|
||||
@msg can't encrypt the PDF: {why} PDF को एन्क्रिप्ट नहीं कर सकता: {why}
|
||||
@msg can't linearise the PDF: {why} PDF को रैखिक नहीं कर सकता: {why}
|
||||
@msg can't open `{_1}`: {_2} `{_1}` नहीं खोल सकते: {_2}
|
||||
@@ -6496,8 +6565,8 @@ antiAlias Sharp शार्प
|
||||
@msg Expand: no gradient fills विस्तार: कोई ग्रेडिएंट भरता नहीं
|
||||
@msg Expand: nothing to expand विस्तार: विस्तार करने के लिए कुछ नहीं
|
||||
@msg expanded SVG styles exceed the import size limit विस्तारित SVG स्टाइल्स आयात आकार सीमा से अधिक है
|
||||
@msg expected {"dataBase64"} उम्मीद {"dataBase64"}
|
||||
@msg expected {"objects"} उम्मीद {"objects"}
|
||||
@msg expected {"dataBase64"} अपेक्षित: {"dataBase64"}
|
||||
@msg expected {"objects"} अपेक्षित: {"objects"}
|
||||
@msg export `{name}`: {e} निर्यात `{name}`: {e}
|
||||
@msg Export cancelled निर्यात रद्द
|
||||
@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft स्क्रीन के लिए एक्सपोर्ट png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai या vectorcraft लिखता है
|
||||
@@ -6692,7 +6761,7 @@ antiAlias Sharp शार्प
|
||||
@msg Invalid dimension font name अमान्य आयाम फ़ॉन्ट नाम
|
||||
@msg Invalid dimension offset अमान्य आयाम ऑफसेट
|
||||
@msg Invalid drawing scale अमान्य ड्राइंग स्केल
|
||||
@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - Invalid id {_1}: A-Z a-z 0-9 से 1-64 अक्षरों का उपयोग करें।
|
||||
@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - अमान्य id {_1}: A-Z a-z 0-9 . _ - में से 1-64 वर्णों का उपयोग करें
|
||||
@msg invalid image data for `{0}` `{0}` के लिए अमान्य इमेज डेटा
|
||||
@msg invalid native panel set अमान्य मूल पैनल सेट
|
||||
@msg invalid object id {value}: expected a non-negative integer अमान्य ऑब्जेक्ट id {value}: एक गैर-नकारात्मक पूर्णांक की उम्मीद
|
||||
@@ -6794,9 +6863,9 @@ antiAlias Sharp शार्प
|
||||
@msg Loading VectorCraft… वेक्टर क्रेफ्ट लोड हो रहा है ...
|
||||
@msg Lock Workspace is not available in VectorCraft लॉक वर्कस्पेस VectorCraft में उपलब्ध नहीं है
|
||||
@msg Make Guides: select paths गाइड बनाना: पाथ सिलेक्ट करें
|
||||
@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) प्रकटन {_1} बाइट्स (limit {MAX_MANIFEST_BYTES})
|
||||
@msg manifest is not UTF-8 manifest: प्रकट नहीं है UTF-8
|
||||
@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) प्रकटन बहुत लंबा है (सीमा {MAX_MANIFEST_BYTES} बाइट्स)
|
||||
@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) मैनिफ़ेस्ट {_1} बाइट्स का है (सीमा {MAX_MANIFEST_BYTES})
|
||||
@msg manifest is not UTF-8 मैनिफ़ेस्ट UTF-8 में नहीं है
|
||||
@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) मैनिफ़ेस्ट बहुत लंबा है (सीमा {MAX_MANIFEST_BYTES} बाइट्स)
|
||||
@msg manifest: {e} प्रकटन: {e}
|
||||
@msg margin: a {m} pt margin leaves no imageable area on the paper मार्जिन: एक {m} pt मार्जिन कागज पर कोई इमेजेबल क्षेत्र नहीं छोड़ता है
|
||||
@msg mask art ends outside a mask dictionary मास्क कला एक मास्क शब्दकोश के बाहर समाप्त होता है
|
||||
@@ -6893,7 +6962,7 @@ antiAlias Sharp शार्प
|
||||
@msg mode is set, add or toggle, not `{other}` mode set, add या toggle है, `{other}` नहीं
|
||||
@msg mode must be "cmyk" or "rgb" mode: मोड "cmyk" या "rgb" होना चाहिए
|
||||
@msg mode must be area|onPath, got {o} mode: मोड होना चाहिए क्षेत्रफल (onPath), {o} मिला
|
||||
@msg module is {_1} bytes (limit {_2}) मॉड्यूल {_1} बाइट्स (सीम {_2}) है।
|
||||
@msg module is {_1} bytes (limit {_2}) मॉड्यूल {_1} बाइट्स का है (सीमा {_2})
|
||||
@msg more than {MAX_BATCH_FILES} data sets {MAX_BATCH_FILES} डेटा सेट से अधिक
|
||||
@msg more than {MAX_BATCH_FILES} files {MAX_BATCH_FILES} फ़ाइलों से अधिक
|
||||
@msg more than {MAX_CSV_COLS} variables: Import Data Sets reads at most {MAX_CSV_COLS} {MAX_CSV_COLS} से अधिक चर: आयात डेटा सेट अधिकांश {MAX_CSV_COLS} पर पढ़ा जाता है।
|
||||
@@ -7189,7 +7258,7 @@ antiAlias Sharp शार्प
|
||||
@msg not a valid SVGZ file: {e} एक मान्य SVGZ फ़ाइल नहीं: {e}
|
||||
@msg not a VectorCraft file: {0} एक वेक्टर क्रेफ्ट फ़ाइल नहीं: {0}
|
||||
@msg not a VectorCraft shortcut set (missing `overrides`) एक VectorCraft शॉर्टकट सेट नहीं है ( `overrides`)
|
||||
@msg not a WebAssembly binary (missing \0asm header) नहीं एक WebAssembly द्विआधारी (missing \0asm header)
|
||||
@msg not a WebAssembly binary (missing \0asm header) WebAssembly बाइनरी नहीं है (\0asm हेडर नहीं है)
|
||||
@msg not a Word document: its compound file has no WordDocument stream एक वर्ड डॉक्यूमेंट नहीं: इसकी यौगिक फ़ाइल में WordDocument स्ट्रीम नहीं है
|
||||
@msg not a Word document: its main part is not a WordprocessingML document वर्ड डॉक्यूमेंट नहीं: इसका मुख्य हिस्सा वर्डप्रोसेसिंगएमएल डॉक्यूमेंट नहीं है
|
||||
@msg not a Word document: its package has no main document part नहीं एक वर्ड डॉक्यूमेंट: इसके पैकेज में कोई मुख्य डॉक्यूमेंट हिस्सा नहीं है
|
||||
@@ -7209,7 +7278,7 @@ antiAlias Sharp शार्प
|
||||
@msg not editing an opacity mask एक opacity मास्क संपादित न करें
|
||||
@msg not found नहीं मिला
|
||||
@msg not in pattern editing mode पैटर्न संपादन मोड में नहीं
|
||||
@msg not JSON: {e} नहीं JSON: {e}
|
||||
@msg not JSON: {e} JSON नहीं है: {e}
|
||||
@msg not type on a path outside perspective दृष्टिकोण के बाहर एक पाथ पर टाइप न करें
|
||||
@msg nothing encoded कुछ भी नहीं
|
||||
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for डेटा सेट {name} में कुछ भी लागू नहीं किया गया था: कोई ऑब्जेक्ट एक चर के लिए बाध्य नहीं है, इसके लिए एक मूल्य है।
|
||||
@@ -7295,7 +7364,7 @@ antiAlias Sharp शार्प
|
||||
@msg Outline Stroke: no stroked paths selected आउटलाइन स्ट्रोक: सिलेक्टेड कोई स्ट्रोक पाथ नहीं
|
||||
@msg outlineText {v}: true or false outlineText {v}: true या false
|
||||
@msg output intent profile: {e} उत्पादन आशय प्रोफ़ाइल: {e}
|
||||
@msg output size ({what} is {len} bytes, limit {max}) उत्पादन का आकार ({what} {len} बाइट्स है, {max} को सीमित करें)
|
||||
@msg output size ({what} is {len} bytes, limit {max}) आउटपुट का आकार ({what} {len} बाइट्स का है, सीमा {max})
|
||||
@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: {_1} फ़ाइलें CMYK हैं, और "{name}" एक RGB प्रोफ़ाइल है
|
||||
@msg output.destination: {e} output.destination: {e}
|
||||
@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: "{name}" नाम की कोई RGB या CMYK प्रोफ़ाइल नहीं है
|
||||
@@ -7366,13 +7435,13 @@ antiAlias Sharp शार्प
|
||||
@msg plane must be left|right|ground plane को left|right|ground होना चाहिए
|
||||
@msg plane must be left|right|ground|none plane को left|right|ground|none होना चाहिए
|
||||
@msg plug-in ABI: {0} प्लग-इन ABI: {0}
|
||||
@msg plug-in exceeded its {0} प्लग-इन अपने {0} से अधिक है
|
||||
@msg plug-in exceeded its {0} प्लग-इन ने अपना {0} पार कर लिया
|
||||
@msg plug-in failed: {0} प्लग-इन विफल: {0}
|
||||
@msg plug-in manifest: {0} प्लग-इन प्रकटन: {0}
|
||||
@msg plug-in manifest: {0} प्लग-इन मैनिफ़ेस्ट: {0}
|
||||
@msg plug-in output: {0} प्लग-इन आउटपुट: {0}
|
||||
@msg plug-in parameters must be a JSON object प्लग-इन पैरामीटर एक JSON ऑब्जेक्ट होना चाहिए
|
||||
@msg plug-in parameters: {0} प्लग-इन पैरामीटर: {0}
|
||||
@msg plug-in trapped: {0} प्लग-इन फँसा: {0}
|
||||
@msg plug-in trapped: {0} प्लग-इन में ट्रैप हुआ: {0}
|
||||
@msg Plug-ins can't be loaded from {path}: {e} प्लग-इन {path} से लोड नहीं किया जा सकता: {e}
|
||||
@msg point is outside the mesh बिंदु मेश के बाहर है
|
||||
@msg points must be [t, left, right] with non-negative widths points: गैर-नकारात्मक चौड़ाई के साथ अंक [t, left, right] होना चाहिए
|
||||
@@ -7794,7 +7863,7 @@ antiAlias Sharp शार्प
|
||||
@msg the object isn't on a perspective plane ऑब्जेक्ट एक पर्सपेक्टिव विमान पर नहीं है
|
||||
@msg the object would cross the horizon of the perspective plane ऑब्जेक्ट पर्सपेक्टिव विमान के क्षितिज को पार करेगी
|
||||
@msg the objects don't match the plan ऑब्जेक्ट्स प्लान से मेल नहीं खाते हैं
|
||||
@msg the objects to change are no longer in the document ऑब्जेक्ट्स को बदलने के लिए अब डॉक्यूमेंट में नहीं हैं
|
||||
@msg the objects to change are no longer in the document बदले जाने वाले ऑब्जेक्ट्स अब डॉक्यूमेंट में नहीं हैं
|
||||
@msg the objects would leave the grid ऑब्जेक्ट्स ग्रिड को छोड़ देंगे
|
||||
@msg the open and permissions passwords must differ खुला और अनुमति पासवर्ड अलग होना चाहिए
|
||||
@msg the open dialog has no Reset ओपन संवाद में कोई रीसेट नहीं है
|
||||
@@ -7854,7 +7923,7 @@ antiAlias Sharp शार्प
|
||||
@msg the replacement font is not available प्रतिस्थापन फ़ॉन्ट उपलब्ध नहीं है
|
||||
@msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) RTF फ़ाइल में कई पैराग्राफ या प्रारूपण परिवर्तन होते हैं जो टाइप (अधिकांश {MAX_PARAGRAPHS} पैराग्राफ और {MAX_RUNS} रन पर) होते हैं।
|
||||
@msg the RTF file is too large to place as type (at most {_1} MB) RTF फ़ाइल बहुत बड़े प्रकार के रूप में जगह है (अधिकांश {_1} MB पर)
|
||||
@msg the runtime panicked रनटाइम panicked
|
||||
@msg the runtime panicked रनटाइम क्रैश हो गया
|
||||
@msg the Save PDF dialog didn't open सेव करें PDF संवाद खुला नहीं था
|
||||
@msg the scale is not a usable number स्केल एक प्रयोग योग्य संख्या नहीं है
|
||||
@msg the screen color sampler is already open स्क्रीन रंग नमूना पहले से ही खुला है
|
||||
@@ -7995,7 +8064,7 @@ antiAlias Sharp शार्प
|
||||
@msg type that changes writing direction inside a text object opens in the direction of its first character टेक्स्ट ऑब्जेक्ट के भीतर लेखन दिशा बदलने वाला टेक्स्ट अपने पहले वर्ण की दिशा में खुलता है
|
||||
@msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip प्रकार जुड़े क्षेत्रों में एक मास्क के रूप में इस्तेमाल किया, रैपराउंड टेक्स्ट या एक मिश्रित पाथ क्लिप नहीं करता है
|
||||
@msg Unembedded to {_1} Unembed to {_1}
|
||||
@msg unexpected host trap अप्रत्याशित मेजबान जाल
|
||||
@msg unexpected host trap होस्ट में अप्रत्याशित ट्रैप
|
||||
@msg unexpected resources अप्रत्याशित संसाधन
|
||||
@msg unknown {k} key `{key}` (size, position) अज्ञात {k} कुंजी `{key}` (आकार, स्थिति)
|
||||
@msg unknown {key} `{v}` (none, short or full) अज्ञात {key} `{v}` (one, short or full)
|
||||
@@ -8063,6 +8132,7 @@ antiAlias Sharp शार्प
|
||||
@msg unknown overprints `{v}` (preserve, simulate or discard) अज्ञात ओवरप्रिंट `{v}` (प्ररक्षित, अनुकरण या त्यागपत्र)
|
||||
@msg unknown panel अज्ञात पैनल
|
||||
@msg unknown panel `{raw}` अज्ञात पैनल `{raw}`
|
||||
@msg panels can't be shown in Presentation Mode प्रस्तुति मोड में पैनल्स नहीं दिखाए जा सकते
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? अज्ञात पैरामीटर `{key}`; क्या आपको `{near}` का मतलब है?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters अज्ञात पैरामीटर `{key}`; कमांड कोई पैरामीटर नहीं लेता है
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} अज्ञात पैरामीटर `{key}`; कमांड लेता है: {keys}
|
||||
@@ -8136,9 +8206,9 @@ antiAlias Sharp शार्प
|
||||
@msg variables are locked चर लॉक कर रहे हैं
|
||||
@msg variables that aren't text were left out: {kinds} वेरिएबल जो टेक्स्ट नहीं छोड़े गए थे: {kinds}
|
||||
@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} vc_abi_version ने {v} लौटाया; यह होस्ट संस्करण {ABI_VERSION} लागू करता है
|
||||
@msg vc_alloc returned 0 (out of memory) vc_alloc 0 लौटे (मेमोरी से बाहर)
|
||||
@msg vc_alloc returned a block outside memory vc_alloc स्मृति के बाहर एक ब्लॉक लौटा
|
||||
@msg vc_run returned error code {packed} vc_run त्रुटि कोड लौटा {packed}
|
||||
@msg vc_alloc returned 0 (out of memory) vc_alloc ने 0 लौटाया (मेमोरी खत्म)
|
||||
@msg vc_alloc returned a block outside memory vc_alloc ने मेमोरी के बाहर का ब्लॉक लौटाया
|
||||
@msg vc_run returned error code {packed} vc_run ने त्रुटि कोड {packed} लौटाया
|
||||
@msg VectorCraft doesn't search other apps' or the system's folders: {path} वेक्टर क्रेफ्ट अन्य एप्लिकेशन या सिस्टम के फ़ोल्डर्स की खोज नहीं करता है: {path}
|
||||
@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} वेक्टर क्रेफ्ट उपयोगकर्ताओं में साझा या सार्वजनिक फ़ोल्डर की खोज नहीं करता है: {path}
|
||||
@msg VectorCraft failed to start: {e} वेक्टर क्रेफ्ट शुरू करने में विफल रहा: {e}
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2106,6 +2106,7 @@ file-filter Templates Sablonok
|
||||
Include Hidden Layers Rejtett rétegek hozzáadása
|
||||
Include Linked Files Csatolt fájlok hozzáadása
|
||||
Include Metadata Metaadatok hozzáadása
|
||||
Include Variable Data Változóadatok hozzáadása
|
||||
Include non-printing layers Nem nyomtatandó rétegek hozzáadása
|
||||
Write pages longer than 200 in at full size in points 200 in-nél hosszabb oldalak írása teljes méretben, pontban
|
||||
Include SVG Code SVG-kód hozzáadása
|
||||
@@ -3513,6 +3514,7 @@ mark-style Japanese Japán
|
||||
Roman Baseline Latin alapvonal
|
||||
Roman Hanging Punctuation Latin függő írásjelek
|
||||
Korean Line Break Anywhere Koreai sortörés bárhol
|
||||
Bunri-Kinshi Bunri-Kinshi
|
||||
East Asian Composition Settings Kelet-ázsiai szedési beállítások
|
||||
Korean Composition Settings Koreai szedési beállítások
|
||||
Rotate Elforgatás
|
||||
@@ -5623,6 +5625,7 @@ imageTrace Outline Körvonal
|
||||
@msg unknown option `{path}.{k}` ismeretlen beállítás: `{path}.{k}`
|
||||
@msg unknown overprints `{o}` (preserve, simulate or discard) ismeretlen felülnyomás-kezelés: `{o}` (preserve, simulate vagy discard)
|
||||
@msg unknown panel `{raw}` ismeretlen panel: `{raw}`
|
||||
@msg panels can't be shown in Presentation Mode bemutató módban a panelek nem jeleníthetők meg
|
||||
@msg unknown preference `{key}` ismeretlen beállítás: `{key}`
|
||||
@msg Preferences imported. A beállítások importálva.
|
||||
@msg not a preferences file: {e} nem beállításfájl: {e}
|
||||
@@ -6110,6 +6113,110 @@ axis Y Axis Y tengely
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` az élő effektus a dokumentum rajzolása közben fut, ezért nem lehet `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out az exportáló bővítmény mindig az exportálás keretét kapja: hagyd ki a `budget` kulcsot
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): egy személynek jóvá kell hagynia az asztali alkalmazásban, vagy add meg az `allowHeavy: true` értéket egy --allow-plugin-grants kapcsolóval indított munkamenetben
|
||||
# Plug-in host messages: manifests, modules, runs and plug-in output
|
||||
@msg `name` must be 1-64 printable characters a `name` 1-64 nyomtatható karakter legyen
|
||||
@msg `version`, `description` or `author` is too long a `version`, a `description` vagy az `author` túl hosszú
|
||||
@msg `warnings` must be a list of strings a `warnings` karakterláncok listája legyen
|
||||
@msg `{k}` must be one of {_1} `{k}`: ezek egyike kell: {_1}
|
||||
@msg `{k}` must be {what} `{k}`: {what} kell
|
||||
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` az exportáló bővítmény 1–{MAX_EXTENSIONS} `extensions` értéket ad meg
|
||||
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` csoporton kívül legfeljebb {MAX_UNGROUPED} paraméter lehet: a többinek adj meg `group` értéket
|
||||
@msg can't encode the input a bemenet nem kódolható
|
||||
@msg expected {"dataBase64"} várt formátum: {"dataBase64"}
|
||||
@msg expected {"objects"} várt formátum: {"objects"}
|
||||
@msg export `{name}`: {e} `{name}` modulexport: {e}
|
||||
@msg extension {e} is listed twice a(z) {e} kiterjesztés kétszer szerepel
|
||||
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot {e} kiterjesztés: 1-16 kisbetűt és számjegyet használj, pont nélkül
|
||||
@msg invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - érvénytelen azonosító: {_1}; 1-64 karaktert használj ezekből: A-Z a-z 0-9 . _ -
|
||||
@msg invalid parameter name {k} érvénytelen paraméternév: {k}
|
||||
@msg manifest is not UTF-8 a leíró nem UTF-8 kódolású
|
||||
@msg manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) a leíró túl hosszú (korlát: {MAX_MANIFEST_BYTES} bájt)
|
||||
@msg manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) a leíró mérete {_1} bájt (korlát: {MAX_MANIFEST_BYTES})
|
||||
@msg module is {_1} bytes (limit {_2}) a modul mérete {_1} bájt (korlát: {_2})
|
||||
@msg needs approval: it asks for {0}, which the user grants when installing it on its own jóváhagyás szükséges: ezt kéri: {0}, amit a felhasználó a külön telepítéskor ad meg
|
||||
@msg not JSON: {e} nem JSON: {e}
|
||||
@msg not a WebAssembly binary (missing \0asm header) nem WebAssembly bináris (hiányzik a \0asm fejléc)
|
||||
@msg parameter `{k}`: {what} `{k}` paraméter: {what}
|
||||
@msg parameter `{name}`: {what} `{name}` paraméter: {what}
|
||||
@msg plug-in parameters must be a JSON object a bővítmény paraméterei JSON-objektumot alkossanak
|
||||
@msg the module must export its linear memory as `memory` a modulnak `memory` néven kell exportálnia a lineáris memóriáját
|
||||
@msg the objects to change are no longer in the document a módosítandó objektumok már nincsenek a dokumentumban
|
||||
@msg the runtime panicked a futtatókörnyezet összeomlott
|
||||
@msg unexpected host trap váratlan megszakítás (trap) a gazdaprogramban
|
||||
@msg vc_abi_version returned {v}; this host implements version {ABI_VERSION} a vc_abi_version {v} értéket adott vissza; ez a gazdaprogram a(z) {ABI_VERSION}. verziót valósítja meg
|
||||
@msg vc_alloc returned 0 (out of memory) a vc_alloc 0-t adott vissza (elfogyott a memória)
|
||||
@msg vc_alloc returned a block outside memory a vc_alloc a memórián kívüli blokkot adott vissza
|
||||
@msg vc_run returned error code {packed} a vc_run {packed} hibakódot adott vissza
|
||||
@msg {_1} is not a file nem fájl: {_1}
|
||||
@msg {_1} is {_2} bytes (limit {_3}) {_1}: {_2} bájt (korlát: {_3})
|
||||
@msg {_1} objects (limit {MAX_OBJECTS}) {_1} objektum (korlát: {MAX_OBJECTS})
|
||||
@msg {at}: {msg} {at}: {msg}
|
||||
@msg {what} points outside memory {what}: a memórián kívülre mutat
|
||||
@msg `angle` must be a number `angle`: szám kell
|
||||
@msg `aspect` must be from 0.01 to 100 `aspect`: 0.01 és 100 közötti érték kell
|
||||
@msg `cap` must be "butt", "round" or "square" `cap`: "butt", "round" vagy "square" kell
|
||||
@msg `fillRule` must be "nonzero" or "evenodd" `fillRule`: "nonzero" vagy "evenodd" kell
|
||||
@msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl`: legfeljebb {MAX_HELP_URL} bájtos https:// cím kell
|
||||
@msg `help` must be 1-{MAX_HELP} printable characters a `help` 1-{MAX_HELP} nyomtatható karakter legyen
|
||||
@msg `id` must be an effect id of 1-{MAX_EFFECT_ID} bytes `id`: 1-{MAX_EFFECT_ID} bájtos effektusazonosító kell
|
||||
@msg `id` must be the id of an input object `id`: egy bemeneti objektum azonosítója kell
|
||||
@msg `join` must be "miter", "round" or "bevel" `join`: "miter", "round" vagy "bevel" kell
|
||||
@msg `label` and `group` must be 1-{MAX_LABEL} printable characters a `label` és a `group` 1-{MAX_LABEL} nyomtatható karakter legyen
|
||||
@msg `maxLength` must be 1-{MAX_TEXT}, and the default no longer a `maxLength` 1-{MAX_TEXT} legyen, és az alapértelmezett érték ne legyen hosszabb
|
||||
@msg `minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many a `minItems` és a `maxItems` 0-{MAX_LIST} legyen, a `maxItems` legalább 1, és az alapértelmezett érték ennyi elemből álljon
|
||||
@msg `name` must be a string `name`: karakterlánc kell
|
||||
@msg `opacity` must be a number `opacity`: szám kell
|
||||
@msg `type` must be "path" or "compound" `type`: "path" vagy "compound" kell
|
||||
@msg `unit` must be 1-{MAX_UNIT} printable characters a `unit` 1-{MAX_UNIT} nyomtatható karakter legyen
|
||||
@msg `visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}` a `visibleWhen` 1-{MAX_CONDITION_VALUES} értéket adjon meg ehhez: `{other}`
|
||||
@msg `visibleWhen` names `{other}`, which isn't a parameter declared before this one a `visibleWhen` ezt nevezi meg: `{other}`, de ez nem az ennél korábban megadott paraméterek egyike
|
||||
@msg `visibleWhen` names at most {MAX_CONDITIONS} parameters a `visibleWhen` legfeljebb {MAX_CONDITIONS} paramétert nevezhet meg
|
||||
@msg `visibleWhen` takes `{other}`'s values, of a checkbox or a choice a `visibleWhen` a(z) `{other}` értékeit veszi át, egy jelölőnégyzetét vagy egy választásét
|
||||
@msg `visible` must be true or false `visible`: true vagy false kell
|
||||
@msg `{key}` must be a number from {_1} to {_2} `{key}`: {_1} és {_2} közötti szám kell
|
||||
@msg `{key}` must be an array of at most {MAX_ITEMS} entries `{key}`: legfeljebb {MAX_ITEMS} elemű tömb kell
|
||||
@msg a color default must be a color object or a hex string such as "#3a7bd5" a szín alapértelmezett értéke színobjektum vagy hexadecimális karakterlánc legyen, például "#3a7bd5"
|
||||
@msg a color object or a hex string such as "#3a7bd5" színobjektum vagy hexadecimális karakterlánc, például "#3a7bd5"
|
||||
@msg a freeform gradient holds at most {MAX_ITEMS} points and lines a szabad formájú színátmenet legfeljebb {MAX_ITEMS} pontot és vonalat tartalmaz
|
||||
@msg a freeform gradient line names a point it doesn't have a szabad formájú színátmenet egyik vonala olyan pontot nevez meg, amely nincs benne
|
||||
@msg a gradient needs 1-{MAX_ITEMS} stops a színátmenethez 1-{MAX_ITEMS} színátmenet-pont kell
|
||||
@msg a list of numbers számok listája
|
||||
@msg a list of {min_items} to {max_items} numbers {min_items}-{max_items} számból álló lista
|
||||
@msg a new object needs a `path` az új objektumhoz `path` kell
|
||||
@msg a number szám
|
||||
@msg a painted live effect returns only `objects` a festő élő effektus csak `objects` értéket ad vissza
|
||||
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` a festő élő effektus darabjának csak `path`, `fillRule`, `fills` és `strokes` kulcsa van
|
||||
@msg a read-only object was left out kimaradt egy írásvédett objektum
|
||||
@msg a read-only object was left out: return it, as {"id"} alone or as it was given kimaradt egy írásvédett objektum: adja vissza önmagában {"id"} formában vagy úgy, ahogy megkapta
|
||||
@msg a string karakterlánc
|
||||
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` a körvonalnak csak `paint`, `width`, `join`, `cap` és `miterLimit` kulcsa van
|
||||
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} a körvonal formája {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} legyen
|
||||
@msg an effect must be {"id", "params"?, "visible"?} az effektus formája {"id", "params"?, "visible"?} legyen
|
||||
@msg an effect on a fill returns only `fills` a kitöltésen lévő effektus csak `fills` értéket ad vissza
|
||||
@msg an effect on a stroke returns only `strokes` a körvonalon lévő effektus csak `strokes` értéket ad vissza
|
||||
@msg an object must be a JSON object az objektum JSON-objektum legyen
|
||||
@msg bad range érvénytelen tartomány
|
||||
@msg choices must be 1-64 non-empty, printable strings a választási lehetőségek 1-64 nem üres, nyomtatható karakterlánc legyenek
|
||||
@msg coordinate out of range (limit ±{MAX_COORD} pt) tartományon kívüli koordináta (korlát: ±{MAX_COORD} pt)
|
||||
@msg default is not one of the options az alapértelmezett érték nem a lehetőségek egyike
|
||||
@msg each piece needs a `path` minden darabhoz `path` kell
|
||||
@msg is listed twice kétszer szerepel
|
||||
@msg pattern transform out of range tartományon kívüli mintázat-transzformáció
|
||||
@msg read-only field '{field}' changed megváltozott a(z) '{field}' írásvédett mező
|
||||
@msg stops must be in order of `offset` a színátmenet-pontok `offset` szerint rendezve legyenek
|
||||
@msg the document has no pattern {pattern} a dokumentumban nincs {pattern} mintázat
|
||||
@msg the document has no swatch {n} a dokumentumban nincs {n} színminta
|
||||
@msg the output a kimenet
|
||||
@msg the path has no anchor points az útvonalnak nincsenek horgonypontjai
|
||||
@msg too many anchor points (limit {MAX_ANCHORS}) túl sok horgonypont (korlát: {MAX_ANCHORS})
|
||||
@msg true or false true vagy false
|
||||
@msg unknown key `{k}`; the keys here are {_1} ismeretlen kulcs: `{k}`; az itt használható kulcsok: {_1}
|
||||
@msg {_1} is listed twice {_1} kétszer szerepel
|
||||
@msg {_1} is not the id of an input object {_1} nem egy bemeneti objektum azonosítója
|
||||
@msg {_1} is read-only {_1} írásvédett
|
||||
@msg {what} must be from {lo} to {hi} {what}: {lo} és {hi} közötti érték kell
|
||||
@msg a color component színösszetevő
|
||||
@msg a plate nyomólemez
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Telepíted a(z) „{name}” bővítményt, amely ezeket az engedélyeket kéri?
|
||||
ID: {id} Azonosító: {id}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user