Merge main into fix/graph-wedge-legend-black-kept

This commit is contained in:
dotCooCoo
2026-11-14 06:08:51 -08:00
110 changed files with 6123 additions and 406 deletions
+2
View File
@@ -124,6 +124,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-11-14: Built-in symbol library Weather Icons: adds a sixth procedural symbol library (Window › Symbol Libraries, the Symbols panel's Symbol Libraries Menu) containing six original vector symbols (Sun, Moon, Cloud, Rain, Thunder, Snow) generated in code without default 9-slice guides (part of Gap 5). Headline numbers unchanged.
- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged.
- 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged.
- 2026-11-13: Banner Hems and Grommets in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): on the chosen sides of the selected art's bounds, or of each artboard, a hem line at a set depth inside the edge and grommet marks (a circle with a cross) at a set inset, one at each end of a side and the rest spread evenly at most a set spacing apart, stroked in a chosen swatch; a dialog with Preview, one undo step (`addon.cutting.banner`). A VectorCraft addition: headline numbers unchanged.
+2 -1
View File
@@ -351,7 +351,8 @@ pub enum Direction {
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "mode")]
pub enum ArtScale {
/// Keep the art's proportions (length follows the path; width scales with it).
/// Keep the art's proportions (length follows the path; width scales with it, times the
/// stroke weight and the brush width).
Proportional,
/// Stretch the art to the path length.
#[default]
+44
View File
@@ -141,6 +141,50 @@ fn art_brush_proportional_keeps_aspect() {
assert!(near(bounds(&out).height(), 8.0, 1e-6));
}
/// Scale Proportionately art is scaled across by the stroke weight and the Width, as Stretch art
/// is, on top of the scale the path length gives it.
#[test]
fn art_brush_proportional_grows_with_weight() {
let brush = |width: f64, overlap: bool| {
let mut b = art_brush(rect_art(100.0, 4.0), ArtScale::Proportional);
if let BrushKind::Art(a) = &mut b.kind {
(a.width, a.overlap) = (width, overlap);
}
b
};
let draw = |b: &Brush, bp: &BezPath, st: &StrokeLayer| stroke_pieces(&Document::new(1.0, 1.0), b, bp, st);
// 3 pt stroke → 3× wider than at 1 pt (8 pt); the length still follows the path.
let b = bounds(&draw(&brush(100.0, false), &line(0.0, 0.0, 200.0, 0.0), &stroke(3.0)));
assert!(near(b.height(), 24.0, 1e-6) && near(b.x0, 0.0, 1e-6) && near(b.x1, 200.0, 1e-6), "{b:?}");
// The Width (50 %) applies on top of the stroke weight.
let b = bounds(&draw(&brush(50.0, false), &line(0.0, 0.0, 200.0, 0.0), &stroke(3.0)));
assert!(near(b.height(), 12.0, 1e-6), "{b:?}");
// A path twice as long at twice the weight (as Scale Strokes & Effects at 200 % leaves it):
// 2× from the length and 2× from the weight.
let b = bounds(&draw(&brush(100.0, false), &line(0.0, 0.0, 400.0, 0.0), &stroke(2.0)));
assert!(near(b.height(), 32.0, 1e-6), "{b:?}");
// With Overlap, each run between corners is scaled by its own length and by the weight: the
// 100 pt run 4 pt × 3 across, the 50 pt run 2 pt × 3.
let mut bent = BezPath::new();
bent.move_to((0.0, 0.0));
bent.line_to((100.0, 0.0));
bent.line_to((100.0, 50.0));
let out = draw(&brush(100.0, true), &bent, &stroke(3.0));
assert_eq!(out.len(), 2);
let (r0, r1) = (out[0].geometric_bounds().unwrap(), out[1].geometric_bounds().unwrap());
assert!(near(r0.height(), 12.0, 1e-6) && near(r1.width(), 6.0, 1e-6), "{r0:?} {r1:?}");
// A width that follows the pen: 50 % at the lightest pressure, 300 % at the heaviest, times
// the 2 pt weight (4 pt art on a 100 pt path: 4 pt across at 100 %, 1 pt either side).
let mut pen = brush(50.0, false);
if let BrushKind::Art(a) = &mut pen.kind {
(a.width_mode, a.width_max) = (Some(Variation::Pressure), Some(300.0));
}
let ramp = StrokeLayer { width: 2.0, ..pressed(vec![(0.0, 0.0), (1.0, 1.0)]) };
let out = draw(&pen, &line(0.0, 0.0, 100.0, 0.0), &ramp);
assert!(near(half_width(&out, 0.0, 1.0), 2.0, 0.1), "{}", half_width(&out, 0.0, 1.0));
assert!(near(half_width(&out, 99.0, 100.0), 12.0, 0.2), "{}", half_width(&out, 99.0, 100.0));
}
#[test]
fn art_brush_between_guides_keeps_ends() {
// Head (last 20 % of a 100 pt art) keeps its 20 pt length on a 300 pt path.
+5 -16
View File
@@ -465,17 +465,8 @@ fn art_runs(t: &Track) -> Vec<(f64, f64)> {
bounds.windows(2).map(|w| (w[0], w[1])).collect()
}
/// The art on [s0, s1] of `t`, `cross` across where `widen` (0..=1, by arc length) is 1, and
/// `width` the width (%) there.
fn art_on_run(
t: &Track,
(s0, s1): (f64, f64),
a: &ArtBrush,
(art, b): (&Node, Rect),
cross: f64,
width: f64,
widen: &dyn Fn(f64) -> f64,
) -> Option<Node> {
/// The art on [s0, s1] of `t`, `cross` across where `widen` (0..=1, by arc length) is 1.
fn art_on_run(t: &Track, (s0, s1): (f64, f64), a: &ArtBrush, (art, b): (&Node, Rect), cross: f64, widen: &dyn Fn(f64) -> f64) -> Option<Node> {
let l = s1 - s0;
if l <= 1e-9 {
return None;
@@ -483,10 +474,8 @@ fn art_on_run(
let w = b.width();
let (along, cross) = match a.scale {
ArtScale::Stretch => (AlongMap::linear(w, s0, s1), cross),
ArtScale::Proportional => {
let k = l / w;
(AlongMap::linear(w, s0, s1), k * width / 100.0)
}
// Scaled across as much as along, times the stroke weight and the width, as the other modes are.
ArtScale::Proportional => (AlongMap::linear(w, s0, s1), l / w * cross),
ArtScale::BetweenGuides { start, end } => {
let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w);
let (g0, g1) = (g0.min(g1), g0.max(g1));
@@ -528,7 +517,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, stroke: &StrokeLayer)
};
let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, l)] };
for run in runs {
if let Some(n) = art_on_run(&t, run, a, (&art, b), cross, width, &widen) {
if let Some(n) = art_on_run(&t, run, a, (&art, b), cross, &widen) {
out.push(n);
}
}
+43
View File
@@ -547,6 +547,49 @@ impl Gradient {
None => (Color::BLACK, 1.0),
}
}
/// Which of its stops color some part of it that draws, by the stretches [`Self::sample`]
/// mixes in, for stops in any order: the pair of stop `k` and stop `k + 1` covers the offsets
/// above every offset of stops `0..=k` up to stop `k + 1`'s, and both its stops count when that
/// stretch has positive width and either stop is above opacity 0. The first stop also counts
/// when it is above opacity 0, since it colors the offsets up to its own, and so does the last
/// stop, since it colors the offsets above every stop's. Any other stop, opaque or not, counts
/// only through a pair: a stop out of order whose pairs have no width (another stop before it
/// reaches past both ends) colors nothing. A freeform gradient draws from its points, so each of
/// its stops counts while any stop is above opacity 0.
pub fn drawn_stops(&self) -> Vec<bool> {
self.stops_with_color(|s| s.opacity > 0.0)
}
/// [`Self::drawn_stops`] with every stop taken as opaque, for output that leaves stop opacity
/// out: the stops that color a stretch of positive width, and the first and last stops.
pub fn reached_stops(&self) -> Vec<bool> {
self.stops_with_color(|_| true)
}
/// [`Self::drawn_stops`] with `shows` telling whether a stop's opacity shows its color.
fn stops_with_color(&self, shows: impl Fn(&GradientStop) -> bool) -> Vec<bool> {
let stops = &self.stops;
if self.kind == GradientKind::Freeform {
let any = stops.iter().any(&shows);
return vec![any; stops.len()];
}
let mut drawn = vec![false; stops.len()];
if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) {
*d = shows(first);
}
if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) {
*d |= shows(last);
}
// `f32::max` skips a NaN offset, which the sampler never picks either.
let mut reach = f32::NEG_INFINITY;
for k in 0..stops.len().saturating_sub(1) {
reach = reach.max(stops[k].offset);
let (a, b) = (&stops[k], &stops[k + 1]);
if b.offset > reach && (shows(a) || shows(b)) {
drawn[k] = true;
drawn[k + 1] = true;
}
}
drawn
}
/// [`Self::sample_with`] at each of `ts`, which must not decrease. Each search starts where
/// the one before it stopped, so the work is the samples plus the stops, not their product.
pub fn sample_increasing_with(&self, ts: impl IntoIterator<Item = f32>, mix: impl Fn(&Color, &Color, f32) -> Color) -> Vec<(Color, f32)> {
+63
View File
@@ -66,6 +66,69 @@ fn stop_reach_matches_scanning_the_stops() {
}
}
/// A gradient's stops color what draws where they color a stretch that draws, for stops in any
/// order: the sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it,
/// so an out-of-order stop's pair can have no width.
#[test]
fn drawn_stops_follow_the_sampled_stretches() {
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) };
let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops);
// In order: the clear stop next to the opaque one, not the one after it.
assert_eq!(g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![true, true, false]);
// Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with
// the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8.
assert_eq!(g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)]).drawn_stops(), vec![false, false, true, true]);
// An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the
// pairs next to the stop at 0.3 have no width and it colors nothing.
let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]);
assert_eq!(out_of_order(0.0).drawn_stops(), vec![false; 5]);
// With the stop at 0.8 opaque, the pairs next to it with width are drawn.
assert_eq!(out_of_order(1.0).drawn_stops(), vec![true, true, false, false, false]);
// The last stop colors the offsets above every stop's, even when it is out of order.
assert_eq!(g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)]).drawn_stops(), vec![false, false, true]);
// The first stop colors the offsets up to its own.
assert_eq!(g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![true, false, false]);
// One stop, every stop clear, and no stops at all.
assert_eq!(g(vec![stop(0.5, 1.0)]).drawn_stops(), vec![true]);
assert_eq!(g(vec![stop(0.0, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![false, false]);
assert!(g(vec![]).drawn_stops().is_empty());
// A freeform gradient draws from its points: its stops count while any is above opacity 0.
let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]);
free.kind = GradientKind::Freeform;
assert_eq!(free.drawn_stops(), vec![true, true, true]);
free.stops.iter_mut().for_each(|s| s.opacity = 0.0);
assert_eq!(free.drawn_stops(), vec![false; 3]);
}
/// Taken as opaque, every stop that colors a stretch of positive width counts, and the first and
/// last stops, whatever their opacity; an out-of-order stop whose pairs have no width does not.
#[test]
fn reached_stops_leave_out_only_stops_without_width() {
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) };
let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops);
// Clear stops in order color their stretches once opaque.
assert_eq!(g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]).reached_stops(), vec![true; 3]);
assert_eq!(g(vec![stop(0.0, 0.0), stop(1.0, 0.0)]).reached_stops(), vec![true, true]);
// The stop at 0.3 sits inside the stretch the stops at 0 and 0.8 cover.
let out_of_order = g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]);
assert_eq!(out_of_order.reached_stops(), vec![true, true, false, true, true]);
// A stop at the offset of the stop before it: its pair has no width, the next one does.
assert_eq!(g(vec![stop(0.0, 1.0), stop(0.0, 1.0), stop(1.0, 1.0)]).reached_stops(), vec![true, true, true]);
assert_eq!(
g(vec![stop(0.0, 1.0), stop(0.6, 1.0), stop(0.6, 1.0), stop(0.2, 1.0), stop(1.0, 1.0)]).reached_stops(),
vec![true, true, false, true, true]
);
assert!(g(vec![]).reached_stops().is_empty());
// Every pair the sampler picks between the ends has both its stops counted.
let stops: Vec<GradientStop> = [0.1, 0.4, 0.4, 0.2, 0.9, 0.5].iter().map(|&o| stop(o, 1.0)).collect();
let g = g(stops);
let (reached, reach) = (g.reached_stops(), StopReach::new(&g.stops));
for t in (5..=95).map(|k| k as f32 / 100.0).filter(|&t| t > 0.1 && t <= 0.9) {
let k = reach.0.partition_point(|r| *r < t);
assert!(reached[k] && reached[k + 1], "pair {k} at {t}: {reached:?}");
}
}
/// Repeated offsets turn off the direct midpoint mix, and the midpoints still come out as
/// `sample` gives them, with work that stays near linear in the stops (thousands of stops at one
/// offset used to scan them all for each midpoint).
+10
View File
@@ -271,6 +271,16 @@ impl Appearance {
_ => None,
})
}
/// Does any fill draw: one that is shown, has paint and has an opacity above 0, wherever it
/// sits in the stack?
pub fn has_drawn_fill(&self) -> bool {
self.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() && f.opacity > 0.0))
}
/// Does any stroke draw: one that is shown, has paint, a weight above 0 and an opacity above
/// 0, wherever it sits in the stack?
pub fn has_drawn_stroke(&self) -> bool {
self.painted_strokes().any(|s| s.width > 0.0 && s.opacity > 0.0)
}
/// Largest distance the painted area can extend beyond any geometry (see
/// [`StrokeLayer::reach`]); paths measure their own with [`Self::stroked_bounds`].
pub fn outset(&self) -> f64 {
+22
View File
@@ -79,6 +79,28 @@ fn basic_means_one_plain_fill_and_stroke() {
assert!(!e.is_basic(), "an effect");
}
#[test]
fn drawn_fills_and_strokes_count_every_row() {
let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p));
let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w));
let black = || Paint::solid(Color::BLACK);
assert!(!Appearance::default().has_drawn_fill() && !Appearance::default().has_drawn_stroke());
// A painted lower row draws under an unpainted top row.
let a = Appearance { items: vec![fill(black()), stroke(black(), 2.0), fill(Paint::None), stroke(Paint::None, 1.0)], ..Appearance::default() };
assert!(a.has_drawn_fill() && a.has_drawn_stroke());
// A hidden painted row, a zero opacity or a zero weight draws nothing.
let mut b = Appearance { items: vec![fill(black()), stroke(black(), 2.0)], ..Appearance::default() };
b.fill_mut().unwrap().visible = false;
b.stroke_mut().unwrap().visible = false;
assert!(!b.has_drawn_fill() && !b.has_drawn_stroke());
let mut c = Appearance::default_art();
c.fill_mut().unwrap().opacity = 0.0;
c.stroke_mut().unwrap().opacity = 0.0;
assert!(!c.has_drawn_fill() && !c.has_drawn_stroke());
let d = Appearance::basic(Paint::None, black(), 0.0);
assert!(!d.has_drawn_fill() && !d.has_drawn_stroke());
}
#[test]
fn set_creates_missing() {
let mut a = Appearance::default();
+109 -16
View File
@@ -515,9 +515,10 @@ pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi:
/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink
/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB),
/// and grayscale with extra samples, of which the first is the alpha when the file says so (other
/// channels are left out). Associated (premultiplied) alpha is divided back out. The samples may be
/// stored together or one plane per channel. `None` for other images and ones larger than
/// [`MAX_CMYK_PIXELS`].
/// channels are left out). Associated (premultiplied) alpha is divided back out, and a pixel with
/// no associated alpha has no color. 16-bit samples are converted at 16 bits, and only the result
/// is rounded to 8 bits. The samples may be stored together or one plane per channel. `None` for
/// other images and ones larger than [`MAX_CMYK_PIXELS`].
pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
use tiff::tags::Tag;
let (kind, mut d) = tiff(bytes)?;
@@ -535,20 +536,33 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3])
_ => return None,
};
let n = usize::try_from(pixels(w, h)?).ok()?;
let data = read_samples(&mut d, n, samples)?;
let mut out = Vec::with_capacity(n * 4);
for p in data.chunks_exact(samples) {
let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX };
let a = f32::from(opacity) / 255.0;
let level = |v: u8| {
let v = f32::from(v) / 255.0;
if extra == Some(1) && a > 0.0 { (v / a).min(1.0) } else { v }
let data = read_samples_at_depth(&mut d, n, samples)?;
// Sample `i` of the image, 0..1.
let level_at = |i: usize| match &data {
Samples::Eight(v) => v.get(i).map(|&s| f32::from(s) / 255.0),
Samples::Sixteen(v) => v.get(i).map(|&s| f32::from(s) / 65535.0),
};
// Sample `i` of the image as 8 bits, a 16-bit sample rounded to the nearest.
let byte_at = |i: usize| match &data {
Samples::Eight(v) => v.get(i).copied(),
Samples::Sixteen(v) => v.get(i).map(|&s| u8::try_from((u32::from(s) + 128) / 257).unwrap_or(u8::MAX)),
};
let mut out = Vec::with_capacity(n.checked_mul(4)?);
for first in (0..n).map(|p| p * samples) {
let (opacity, a) = if alpha { (byte_at(first + colour).unwrap_or(u8::MAX), level_at(first + colour).unwrap_or(1.0)) } else { (u8::MAX, 1.0) };
let level = |s: usize| {
let v = level_at(first + s).unwrap_or(0.0);
match extra {
Some(1) if a > 0.0 => (v / a).min(1.0),
// No associated alpha: the color is zero (TIFF 6.0, Associated Alpha Handling).
Some(1) if alpha => 0.0,
_ => v,
}
};
let [r, g, b] = match *p {
[c, m, y, k, ..] if colour == 4 => rgb([level(c), level(m), level(y), level(k)]),
[v, ..] if photometric == Some(0) => [1.0 - level(v); 3],
[v, ..] => [level(v); 3],
[] => continue,
let [r, g, b] = match colour {
4 => rgb([level(0), level(1), level(2), level(3)]),
_ if photometric == Some(0) => [1.0 - level(0); 3],
_ => [level(0); 3],
};
out.extend([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
out.push(opacity);
@@ -767,6 +781,85 @@ mod tests {
assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque");
}
/// Five 16-bit samples a pixel: the four inks and one extra sample, which the file's
/// `ExtraSamples` names.
struct Cmyka16;
impl tiff::encoder::colortype::ColorType for Cmyka16 {
type Inner = u16;
const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::CMYK;
const BITS_PER_SAMPLE: &'static [u16] = &[16; 5];
const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; 5];
fn horizontal_predict(row: &[u16], result: &mut Vec<u16>) {
result.extend_from_slice(row);
}
}
/// Two 16-bit samples a pixel: gray and one extra sample.
struct GrayExtra16;
impl tiff::encoder::colortype::ColorType for GrayExtra16 {
type Inner = u16;
const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::BlackIsZero;
const BITS_PER_SAMPLE: &'static [u16] = &[16, 16];
const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; 2];
fn horizontal_predict(row: &[u16], result: &mut Vec<u16>) {
result.extend_from_slice(row);
}
}
/// A one-row TIFF of `C`'s pixels `data`, with the ExtraSamples value `extra`.
fn extra_tiff<C: tiff::encoder::colortype::ColorType<Inner = u16>>(extra: u16, data: &[u16]) -> Vec<u8> {
let samples = C::BITS_PER_SAMPLE.len();
let mut file = Cursor::new(Vec::new());
let mut enc = tiff::encoder::TiffEncoder::new(&mut file).unwrap();
let mut img = enc.new_image::<C>(u32::try_from(data.len() / samples).unwrap(), 1).unwrap();
img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[extra][..]).unwrap();
img.write_data(data).unwrap();
file.into_inner()
}
/// 16-bit CMYK with alpha converts its 16-bit inks and alpha, and rounds only the result to 8
/// bits; the high byte of each sample alone would give other pixels.
#[test]
fn a_16_bit_cmyk_tiff_with_alpha_converts_at_16_bits() {
let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)];
let read = |extra: u16, data: &[u16]| extra_samples_tiff_rgba(&extra_tiff::<Cmyka16>(extra, data), naive).expect("CMYKA reads").into_raw();
// Unassociated: 129 of cyan, opaque; no ink at an alpha of 129; no ink at an alpha of 386.
let straight = [129, 0, 0, 0, 65535, 0, 0, 0, 0, 129, 0, 0, 0, 0, 386];
assert_eq!(read(2, &straight), [254, 255, 255, 255, 255, 255, 255, 1, 255, 255, 255, 2]);
// Associated: cyan 128 at an alpha of 256 (half cyan); K 255 at an alpha of 511 (about half K).
let premultiplied = [128, 0, 0, 0, 256, 0, 0, 0, 255, 511];
assert_eq!(read(1, &premultiplied), [128, 255, 255, 1, 128, 128, 128, 2], "divided by the 16-bit alpha");
}
/// 16-bit gray with alpha converts at 16 bits as well.
#[test]
fn a_16_bit_grayscale_tiff_with_alpha_converts_at_16_bits() {
let read = |extra: u16| {
// A gray of 129, opaque, then 128 at an alpha of 256.
let bytes = extra_tiff::<GrayExtra16>(extra, &[129, 65535, 128, 256]);
extra_samples_tiff_rgba(&bytes, |_| [0.0; 3]).expect("gray with alpha reads").into_raw()
};
assert_eq!(read(2), [1, 1, 1, 255, 0, 0, 0, 1], "unassociated");
assert_eq!(read(1), [1, 1, 1, 255, 128, 128, 128, 1], "associated, divided by the 16-bit alpha");
}
/// With associated alpha, a pixel whose alpha is zero has no color (TIFF 6.0, Associated
/// Alpha Handling), whatever its color samples hold.
#[test]
fn a_pixel_with_no_associated_alpha_has_no_color() {
use tiff::encoder::{TiffEncoder, colortype::CMYKA8};
let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)];
let mut file = Cursor::new(Vec::new());
let mut enc = TiffEncoder::new(&mut file).unwrap();
let mut img = enc.new_image::<CMYKA8>(1, 1).unwrap();
img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap();
img.write_data(&[255, 0, 0, 0, 0]).unwrap();
let cmyk = extra_samples_tiff_rgba(&file.into_inner(), naive).expect("CMYKA reads");
assert_eq!(cmyk.into_raw(), [255, 255, 255, 0], "no ink");
let gray = extra_samples_tiff_rgba(&extra_tiff::<GrayExtra16>(1, &[50_000, 0]), |_| [0.0; 3]).expect("gray reads");
assert_eq!(gray.into_raw(), [0, 0, 0, 0], "a gray of zero");
}
/// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel,
/// times 2 planes.
#[test]
+24 -9
View File
@@ -26,20 +26,20 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2};
use crate::distort::{transform_move, transform_offsets};
use crate::group::{has_own_paint, paints};
use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves};
use crate::reshape::{as_art, clipping_text_outline, 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_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape,
has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape,
};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
pub(crate) fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
match item {
AppearanceItem::Fill(f) => &f.effects,
AppearanceItem::Stroke(s) => &s.effects,
}
}
fn clear_item_effects(item: &mut AppearanceItem) {
pub(crate) fn clear_item_effects(item: &mut AppearanceItem) {
match item {
AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects),
AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects),
@@ -74,7 +74,7 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
/// `path` with the geometry effects of `effects` applied, and how gradients follow them
/// ([`crate::Carry`]).
fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) {
pub(crate) fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) {
if !has_geometry(effects) || path.is_empty() {
return (path.clone(), crate::Carry::default());
}
@@ -86,7 +86,7 @@ fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext
/// context of) applied, and `carried` (`item` as the object's effects left it) with its gradient
/// moved along with them and the Transform effects the geometry pass left out hidden
/// ([`crate::Carry::hide_skipped`]).
fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) {
pub(crate) fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) {
let (ig, carry) = apply(item_effects(item), g, &ctx.item(item));
let mut ap = Appearance { items: vec![carried.clone()], ..Default::default() };
if carry.moves(&ap) {
@@ -202,7 +202,22 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
return bake_leaf(d, n);
}
let mut m = n.clone();
let baked: Vec<Arc<Node>> = ch.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone())).collect();
// A clipping path takes the geometry effects in it as the canvas clips by it, those of a
// compound path's paths before the compound path's own ([`crate::clip::with_clip_effects_applied`]);
// the pieces it is made of then get ids of their own.
let clip = usize::from(n.clips());
let baked: Vec<Arc<Node>> = ch
.iter()
.enumerate()
.map(|(i, c)| match (i < clip).then(|| crate::clip::with_clip_effects_applied(c)).flatten() {
Some(a) => {
let mut a = bake_node(d, &a).unwrap_or(a);
fresh_ids(d, &mut a, &mut std::collections::HashSet::new());
Arc::new(a)
}
None => bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone()),
})
.collect();
if let Some(ch) = m.children_mut() {
*ch = baked;
}
@@ -407,7 +422,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
last => Some(Node::group(c.id, copies.into_iter().chain(last).map(Arc::new).collect())),
}
}
None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
None if clipping => clipping_text_outline(c),
None => as_art(Some(d), c, None),
};
if let Some(o) = art {
@@ -469,7 +484,7 @@ fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: Appea
/// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of
/// its effects, and `n` without those effects, its gradients moved along with them.
fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
pub(crate) fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
let (base, rule) = geometry(n)?;
let ctx = GeomContext::of(n);
let mut m = n.clone();
+3 -3
View File
@@ -793,7 +793,7 @@ fn type_outlined_next_to_type_that_stays_type_keeps_its_look() {
}
#[test]
fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
fn type_in_a_clipping_group_with_a_fill_of_its_own_keeps_its_effect() {
if skip_without_craft_fonts() {
return;
}
@@ -808,9 +808,9 @@ fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
g.children_mut().unwrap().push(Arc::new(clip));
let d = bake_document(&doc_with(g)).unwrap();
assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]);
// The type clips by its plain outlines, as the canvas clips by them: its own effect is left out.
// The type clips by its outlines moved by its own effect, as the canvas clips by them.
let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap();
assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}");
assert!(b.y0 > 330.0 && b.y1 < 370.0, "{b:?}");
}
#[test]
+117 -2
View File
@@ -7,9 +7,124 @@ use vectorcraft_geom::{BezPath, FillRule, PathData};
/// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their
/// holes, even-odd paths their rule, text clips by its glyph outlines and a group by the union of
/// its members. `None` when there is nothing to clip by (the clipped art is then hidden).
/// its members. The live geometry effects of the clipping path (and of the members it clips by)
/// reshape the region, as they reshape what it paints, and so do the geometry effects of a path's
/// fills and strokes: the region is then the union of the paths they give, as an export bakes them.
/// `None` when there is nothing to clip by (the clipped art is then hidden).
pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> {
clip.clip_outline(Some(&outline_text), &unite)
match clip_effects_applied(clip, true) {
Some(m) => m.clip_outline(Some(&outline_text), &unite),
None => clip.clip_outline(Some(&outline_text), &unite),
}
}
/// `n`, the clipping path of a clip group, with the live geometry effects of everything it clips by
/// applied, innermost first, and removed: a path or compound path with effects becomes a path of
/// its reshaped geometry that keeps its fills, strokes and other effects (its gradients follow the
/// effects, as an export bakes it), the paths of a compound path and the visible members of a group
/// are reshaped by their own effects before the effects around them, a group's or layer's own
/// effects reshape its members as an export evaluates them, and other objects with effects (type)
/// become a path of their reshaped region. A path or compound path whose fills or strokes have
/// geometry effects becomes a group of one path per fill and stroke, reshaped by the path's effects
/// and then by that fill's or stroke's, as an export bakes it; [`clip_outline`] clips by their
/// union. A clipping path that paints ([`Node::clip_paint`], which a group does not) keeps the
/// geometry effects of its own fills and strokes live instead. `None` when nothing in it has a
/// geometry effect. Effects on the clip group or around it then reshape the result, after its own.
pub(crate) fn with_clip_effects_applied(n: &Node) -> Option<Node> {
let paint = n.clip_paint();
clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none())
}
/// [`with_clip_effects_applied`]; with `items` unset (a clipping path that paints, and the paths of
/// a compound path, which draw with the compound path's fills and strokes), the geometry effects of
/// `n`'s own fills and strokes are left out.
fn clip_effects_applied(n: &Node, items: bool) -> Option<Node> {
let mut m: Option<Node> = None;
let members: &[std::sync::Arc<Node>] = match &n.kind {
NodeKind::Group { children, clip: true } => children.get(..1).unwrap_or_default(),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => children,
_ => &[],
};
// A group or layer clips by its visible members after its shaper; a compound path by all of
// its paths, and a nested clip group by its clipping path, shown or hidden
// ([`Node::clip_shapes`]).
let all = matches!(n.kind, NodeKind::Compound { .. } | NodeKind::Group { clip: true, .. });
let skip = usize::from(matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. }) && n.extras().shaper.is_some());
// The paths of a compound path draw with the compound path's fills and strokes, and stay paths.
let compound = matches!(n.kind, NodeKind::Compound { .. });
for (i, c) in members.iter().enumerate().skip(skip).filter(|(_, c)| all || c.visible) {
if let Some(r) = clip_effects_applied(c, items && !compound)
&& let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i))
{
*slot = std::sync::Arc::new(r);
}
}
let items = items
&& matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
&& n.appearance.items.iter().any(|i| crate::has_geometry(crate::bake::item_effects(i)));
if items && let Some((g, rule, ctx, mut p)) = crate::bake::leaf_base(m.as_ref().unwrap_or(n)) {
let children = n
.appearance
.items
.iter()
.zip(&p.appearance.items)
.map(|(item, carried)| {
let (ig, mut it) = crate::bake::apply_item(item, carried, &g, &ctx);
crate::bake::clear_item_effects(&mut it);
let mut piece = Node::path(n.id, ig, Appearance { items: vec![it], ..Default::default() });
if let NodeKind::Path { rule: r, .. } = &mut piece.kind {
*r = rule;
}
std::sync::Arc::new(piece)
})
.collect();
// The path's transparency and raster effects stay on the group of its pieces.
p.appearance.items.clear();
p.kind = NodeKind::Group { children, clip: false };
return Some(p);
}
if !crate::has_geometry(&n.appearance.effects) {
return m;
}
let cur = m.as_ref().unwrap_or(n);
// A group's or layer's effects reshape each of its members (a clip group's its clipping
// path), as an export evaluates them; otherwise they reshape its region, below.
if matches!(cur.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
&& let Some(e) = crate::group::evaluate_container(None, cur)
{
return Some(e);
}
// A path or compound path, as an export bakes it.
if let Some((g, rule, _, mut p)) = crate::bake::leaf_base(cur) {
let clipping = match &cur.kind {
NodeKind::Path { clipping, .. } => *clipping,
NodeKind::Compound { children, .. } => children.iter().any(|c| matches!(c.kind, NodeKind::Path { clipping: true, .. })),
_ => false,
};
p.kind = Node::path(n.id, g, Appearance::default()).kind;
if let NodeKind::Path { rule: r, clipping: c, .. } = &mut p.kind {
(*r, *c) = (rule, clipping);
}
return Some(p);
}
// Type, as its painted outlines that its effects reshape as an export evaluates them.
if matches!(cur.kind, NodeKind::Text(_))
&& let Some(mut o) = crate::outline_text(cur)
{
o.visible = n.visible;
o.appearance.effects = n.appearance.effects.clone();
if let Some(e) = crate::group::evaluate_container(None, &o) {
return Some(e);
}
}
let (bp, rule) = cur.clip_outline(Some(&outline_text), &unite)?;
let (g, _) = crate::bake::apply(&n.appearance.effects, &PathData::from_bezpath(&bp), &crate::GeomContext::of(n));
let mut p = Node::path(n.id, g, Appearance::default());
if let NodeKind::Path { rule: r, .. } = &mut p.kind {
*r = rule;
}
p.visible = n.visible;
Some(p)
}
/// The knockout shape of a piece of a mesh-warped image ([`Node::knockout_outline`]) painted opaque
+11 -3
View File
@@ -845,9 +845,10 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
/// Replace each visible member of `n` with geometry or Pathfinder effects of its own, at every
/// depth of its groups and layers, by the art it draws ([`evaluate_container`],
/// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members
/// draw. A clip group's clipping path stays as it is, and so do images, symbol instances and the
/// other objects that `own_effects_art` leaves alone, and a group or layer with a 3D effect with
/// everything in it. Calls nested in one another, through the
/// draw. A clip group's clipping path only takes the geometry effects in it into its geometry
/// ([`crate::clip::with_clip_effects_applied`]). Images, symbol instances and the other objects
/// that `own_effects_art` leaves alone stay as they are, and so does a group or layer with a 3D
/// effect with everything in it. Calls nested in one another, through the
/// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members
/// included), stop at [`vectorcraft_doc::depth::MAX_DEPTH`] deep, and together they replace at
/// most [`crate::SYMBOL_ART_MAX_NODES`] members: past either limit, members stay as they are.
@@ -855,6 +856,13 @@ pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) {
let Some(_frame) = DrawnFrame::enter() else { return };
let clip = usize::from(n.clips());
let Some(ch) = n.children_mut() else { return };
// The clipping path's own geometry effects shape it before the effects around it do.
if clip == 1
&& let Some(first) = ch.first_mut()
&& let Some(applied) = crate::clip::with_clip_effects_applied(first)
{
*first = Arc::new(applied);
}
for c in ch.iter_mut().skip(clip).filter(|c| c.visible) {
if !DrawnFrame::take_member() {
return;
+5 -3
View File
@@ -747,9 +747,9 @@ fn a_baked_group_that_keeps_its_type_applies_its_members_effects_first() {
assert!((w - 250.0).abs() < 1e-6, "{w}");
}
/// A clip group's clipping path keeps its own effects; the clipped members take theirs first.
/// A clip group's clipping path and the clipped members take their own effects first.
#[test]
fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() {
fn a_clipping_path_takes_its_own_effects_before_a_groups_effects() {
let mut clip = with_effect(square_at_100(), offset_10());
clip.id = NodeId(2);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
@@ -762,7 +762,9 @@ fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() {
let clip_group = art.children().unwrap()[0].clone();
let ch = clip_group.children().unwrap();
assert!(matches!(ch[0].kind, NodeKind::Path { clipping: true, .. }), "{:?}", ch[0].kind);
assert_eq!(crate::reshape::geometry_effects(&ch[0]).len(), 1, "the clipping path keeps its Offset Path");
assert!(crate::reshape::geometry_effects(&ch[0]).is_empty(), "the clipping path's Offset Path is applied");
let w = ch[0].geometric_bounds().unwrap().width();
assert!((w - 60.0).abs() < 0.5, "grown by 10 on each side, then scaled to 50 %: {w}");
assert!(matches!(ch[1].kind, NodeKind::Group { .. }) && crate::reshape::geometry_effects(&ch[1]).is_empty(), "the member is drawn as its art");
}
+14 -4
View File
@@ -689,18 +689,28 @@ fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook,
}
}
/// Type `t` in a clip group's clipping path as its outlines ([`outline_text`]) with its
/// visibility, or with geometry effects as a path of the region they give it, which is what
/// [`crate::clip_outline`] clips by.
pub(crate) fn clipping_text_outline(t: &Node) -> Option<Node> {
if let Some(p) = crate::clip::with_clip_effects_applied(t) {
return Some(p);
}
let o = outline_text(t)?;
Some(Node { visible: t.visible, ..o })
}
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
/// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes
/// its plain outlines ([`outline_text`]) with its visibility, which is what
/// [`crate::clip_outline`] clips by. The swatches and patterns that live-effect plug-ins painting
/// their pieces name are looked up in `doc`.
/// its outlines with its visibility and effects ([`clipping_text_outline`]). The swatches and
/// patterns that live-effect plug-ins painting their pieces name are looked up in `doc`.
pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) {
let clips = n.clips();
let Some(ch) = n.children_mut() else { return };
for (i, c) in ch.iter_mut().enumerate() {
let clipping = clipping || (clips && i == 0);
let art = match &c.kind {
NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }),
NodeKind::Text(_) if clipping => clipping_text_outline(c),
NodeKind::Text(_) => as_art(doc, c, None),
_ => None,
};
+26 -3
View File
@@ -223,6 +223,10 @@ pub(crate) fn solid_paint_sources(n: &Node) -> (Vec<Option<&AppearanceItem>>, Ve
}
fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
#[cfg(test)]
if !is_plane(id) {
SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1));
}
let view = view_options(p);
match id {
EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view),
@@ -292,7 +296,7 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at:
let mut shapes = members(n, label)?;
reshape_members(n, before, &mut shapes);
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))?;
check_profiles(id, p, &shapes, b)?;
check_solids(id, p, &shapes)?;
return cast_shadow(id, p, &shapes).map(|_| ());
}
@@ -309,12 +313,24 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at:
None => (geometry(&path, before, &ctx), solid_paints(n)),
};
let b = path.bounds().ok_or_else(anchors)?;
facets(id, &path, rule, b, p)?;
let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }];
check_profiles(id, p, &shapes, b)?;
check_solids(id, p, &shapes)?;
cast_shadow(id, p, &shapes).map(|_| ())
}
/// Do the solids 3D effect `id` with parameters `p` builds from the profiles of `shapes` about `b`
/// build? Only the profiles no solid of [`check_solids`] is built from are built here: every
/// shape's for Rotate, and for Extrude & Bevel and Inflate the profile of a shape with no fill,
/// whose solids are its strokes' outlines or none. A filled shape's profile is its fill's solid,
/// which [`check_solids`] builds once, about the bounds the effect draws it about.
fn check_profiles(id: &str, p: &Value, shapes: &[Profile], b: Rect) -> Result<(), String> {
shapes
.iter()
.filter(|s| is_plane(id) || !s.items.iter().any(|i| matches!(i, AppearanceItem::Fill(_))))
.try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))
}
/// Do the solids 3D effect `id` with parameters `p` builds from the fills and strokes of `shapes`
/// fit, each on its own and all of them together ([`built_solids`])? Rotate's planes are not
/// counted.
@@ -658,6 +674,9 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<Part<'_>>, Rect) {
thread_local! {
/// How many solids [`built_solids`] and [`meshes_for`] have built on this thread, for the tests.
static SOLIDS_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// How many Extrude & Bevel (Classic included) and Inflate solids [`facets`] and
/// [`built_solids`] have built on this thread, for the tests.
static SOLID_GEOMETRY_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The solids Extrude & Bevel (Classic included) or Inflate `id` with parameters `p` builds from
@@ -687,7 +706,11 @@ fn built_solids<'a>(
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + 1));
let built = match inflated.as_mut() {
Some(each) => each.next().unwrap_or_else(|| Ok(vec![])),
Some(each) => {
#[cfg(test)]
SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1));
each.next().unwrap_or_else(|| Ok(vec![]))
}
None => facets(id, area, *rule, bounds, p),
};
let result = match built {
+42
View File
@@ -953,6 +953,48 @@ fn an_objects_solids_count_their_faces_together() {
}
}
/// How many Extrude & Bevel and Inflate solids `check` builds, and its result.
fn geometry_builds(check: impl FnOnce() -> Result<(), String>) -> (usize, Result<(), String>) {
SOLID_GEOMETRY_BUILT.with(|b| b.set(0));
let result = check();
(SOLID_GEOMETRY_BUILT.with(std::cell::Cell::get), result)
}
/// The check builds each solid the effect draws once, about the bounds it draws it about: one for
/// a fill and one for each stroke, on an object, on a group's members and on an appearance item,
/// with the shadow on or off.
#[test]
fn the_check_builds_each_drawn_solid_once() {
for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] {
let n = square_node();
assert_eq!(geometry_builds(|| validate_solid(&n, id, &json!({}))), (2, Ok(())), "{id}: a fill and a stroke");
let mut other = square_node();
other.id = NodeId(2);
let g = Node::group(NodeId(9999), vec![Arc::new(square_node()), Arc::new(other)]);
assert_eq!(geometry_builds(|| validate_solid(&g, id, &json!({}))), (4, Ok(())), "{id}: two members' fills and strokes");
}
for id in [EXTRUDE, INFLATE] {
let n = square_node();
assert_eq!(geometry_builds(|| validate_three_d_at(&n, id, &json!({}), (Some(0), 0))), (1, Ok(())), "{id}: on the fill");
let shadow = json!({"shadow": true});
assert_eq!(geometry_builds(|| validate_solid(&n, id, &shadow)), (2, Ok(())), "{id}: with the shadow on");
}
}
/// A shape with no fill draws no solid of its profile, so the check builds the profile on its own
/// as well as the strokes' solids, and an unpainted shape's profile alone.
#[test]
fn the_check_builds_the_profile_of_a_shape_with_no_fill() {
for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] {
let mut stroked = square_node();
stroked.appearance.items.retain(|i| matches!(i, AppearanceItem::Stroke(_)));
assert_eq!(geometry_builds(|| validate_solid(&stroked, id, &json!({}))), (2, Ok(())), "{id}: a stroke alone");
let mut bare = square_node();
bare.appearance.items.clear();
assert_eq!(geometry_builds(|| validate_solid(&bare, id, &json!({}))), (1, Ok(())), "{id}: no paint");
}
}
/// A convex polygon of `corners` corners about (20,000, 20,000), filled red, with no stroke.
fn polygon_node(id: u64, corners: usize) -> Node {
let points: Vec<Point> = (0..corners)
+1
View File
@@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes};
use super::*;
mod clipfx;
mod imageoutline;
mod painted;
mod rasterfollow;
+401
View File
@@ -0,0 +1,401 @@
//! Live effects on the clipping path of a clip group: the region it clips to takes the shape its
//! geometry effects give it, on the canvas as in an export.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind};
use vectorcraft_geom::{Rect, Shape, shapes};
use crate::{bake_document, clip_outline};
fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
/// A 100 pt square clipping path at (50, 50) with `effects`.
fn clipping_path(id: u64, effects: Vec<Effect>) -> Node {
let mut p = Node::path(NodeId(id), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut p.kind {
*clipping = true;
}
p.appearance.effects = effects;
p
}
/// A clip group: `clip` clipping a red 200 pt square at the origin.
fn clip_group(id: u64, clip: Node) -> Node {
let art = Node::path(
NodeId(id + 100),
shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)),
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
);
Node::new(NodeId(id), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true })
}
fn bounds(n: &Node) -> Rect {
clip_outline(n).map(|(bp, _)| bp.bounding_box()).unwrap()
}
/// The clipping path of the first clip group in `doc` after an export bakes it.
fn baked_clip(doc: &Document) -> Arc<Node> {
fn first(n: &Arc<Node>) -> Option<Arc<Node>> {
match &n.kind {
NodeKind::Group { children, clip: true } => children.first().cloned(),
_ => n.children()?.iter().find_map(first),
}
}
let baked = bake_document(doc).unwrap();
baked.layers.iter().find_map(first).unwrap()
}
#[test]
fn transform_moves_the_clip() {
let clip = clipping_path(1, vec![fx("distort.transform", json!({"moveH": 60}))]);
let b = bounds(&clip);
assert!((b.x0 - 110.0).abs() < 1e-6 && (b.x1 - 210.0).abs() < 1e-6, "the clip moves with the clipping path: {b:?}");
assert!((b.y0 - 50.0).abs() < 1e-6 && (b.y1 - 150.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn roughen_clips_as_an_export_does() {
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
let group = clip_group(1, clipping_path(2, vec![fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false}))]));
d.insert(Some(layer), 0, group.clone()).unwrap();
let NodeKind::Group { children, .. } = &group.kind else { panic!("not a group") };
let live = clip_outline(&children[0]).unwrap();
let exported = clip_outline(&baked_clip(&d)).unwrap();
assert_eq!(live.1, exported.1);
assert_eq!(live.0, exported.0, "the canvas clips by the outline the export writes");
assert_ne!(live.0.bounding_box(), Rect::new(50.0, 50.0, 150.0, 150.0), "the roughened outline, not the plain square");
}
#[test]
fn nested_members_keep_their_effects() {
// A group as the clipping path: its member's Transform effect moves its part of the region.
let moved = clipping_path(2, vec![fx("distort.transform", json!({"moveV": 60}))]);
let group = Node::group(NodeId(1), vec![Arc::new(moved)]);
let b = bounds(&group);
assert!((b.y0 - 110.0).abs() < 1e-6 && (b.y1 - 210.0).abs() < 1e-6, "{b:?}");
// A clip group as the clipping path clips by its own clipping path, effects and all.
let nested = clip_group(3, clipping_path(4, vec![fx("distort.transform", json!({"moveH": -30}))]));
let b = bounds(&nested);
assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 120.0).abs() < 1e-6, "{b:?}");
// So does the nested clip group's own effect.
let mut nested = clip_group(7, clipping_path(8, vec![]));
nested.appearance.effects.push(fx("distort.transform", json!({"moveH": 30})));
let b = bounds(&nested);
assert!((b.x0 - 80.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6, "{b:?}");
// The group's own Transform effect applies on top of its members'.
let mut group = Node::group(NodeId(5), vec![Arc::new(clipping_path(6, vec![fx("distort.transform", json!({"moveV": 60}))]))]);
group.appearance.effects.push(fx("distort.transform", json!({"moveH": 10})));
let b = bounds(&group);
assert!((b.x0 - 60.0).abs() < 1e-6 && (b.y0 - 110.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn a_roughened_group_clips_as_an_export_does() {
// Two members side by side, Roughen on the group: each member is roughened, as in the export.
let member = |id: u64, x: f64| {
Arc::new(Node::path(
NodeId(id),
shapes::rectangle(Rect::new(x, 50.0, x + 40.0, 150.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
))
};
let mut clip = Node::group(NodeId(2), vec![member(3, 50.0), member(4, 110.0)]);
clip.appearance.effects.push(fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false})));
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, clip_group(1, clip.clone())).unwrap();
let live = clip_outline(&clip).unwrap();
assert_eq!(live.0, clip_outline(&baked_clip(&d)).unwrap().0, "the canvas clips by the outline the export writes");
assert_ne!(live.0.bounding_box(), Rect::new(50.0, 50.0, 150.0, 150.0));
}
#[test]
fn a_compound_paths_member_keeps_its_effect() {
// A ring whose hole a Transform effect moves 20 pt right, as the export bakes it.
let ring = |effects: Vec<Effect>| {
let outer = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default());
let mut hole = Node::path(NodeId(4), shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0)), Appearance::default());
hole.appearance.effects = effects;
Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(hole)], rule: vectorcraft_geom::FillRule::EvenOdd })
};
let moved = ring(vec![fx("distort.transform", json!({"moveH": 20}))]);
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, clip_group(1, moved.clone())).unwrap();
let live = clip_outline(&moved).unwrap();
assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes");
assert_ne!(live, clip_outline(&ring(vec![])).unwrap(), "the hole moves");
// With a Transform of the compound path's own moving the whole ring 10 pt down after it.
let mut both = moved.clone();
both.appearance.effects.push(fx("distort.transform", json!({"moveV": 10})));
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, clip_group(1, both.clone())).unwrap();
let live = clip_outline(&both).unwrap();
assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes");
assert_ne!(live.0.bounding_box(), clip_outline(&moved).unwrap().0.bounding_box(), "the ring moves down");
}
#[test]
fn the_clipping_paths_effects_apply_before_the_clip_groups() {
// Scale 50 % on the clipping path, then Offset Path 10 on its clip group: the region is the
// half-size square grown by 10, as if the clipping path had been drawn at half size.
let group = |clip: Node| {
let mut g = clip_group(1, clip);
g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10})));
g
};
let scaled = group(clipping_path(2, vec![fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))]));
let mut half = clipping_path(2, vec![]);
if let NodeKind::Path { path, .. } = &mut half.kind {
*path = shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0));
}
let want = bounds(&group(half));
let got = bounds(&scaled);
assert!((got.x0 - want.x0).abs() < 0.5 && (got.x1 - want.x1).abs() < 0.5, "{got:?} against {want:?}");
assert!((want.x0 - 65.0).abs() < 0.5, "{want:?}");
// The clip group as the canvas evaluates it for its effect, and as an export bakes it.
let evaluated = crate::evaluate_container(None, &scaled).unwrap();
let NodeKind::Group { children, clip: true } = &evaluated.kind else { panic!("not a clip group") };
let b = clip_outline(&children[0]).unwrap().0.bounding_box();
assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "evaluated: {b:?} against {want:?}");
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, scaled).unwrap();
let b = clip_outline(&baked_clip(&d)).unwrap().0.bounding_box();
assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "exported: {b:?} against {want:?}");
}
#[test]
fn nested_effects_apply_before_the_clip_groups() {
// The clip group grows its clipping path by 10 (Offset Path). Inside the clipping path a
// Transform moves a member 60 pt right: the region grows from the moved member.
let evaluated = |clip: Node| {
let mut g = clip_group(1, clip);
g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10})));
let e = crate::evaluate_container(None, &g).unwrap();
let NodeKind::Group { children, clip: true } = &e.kind else { panic!("not a clip group") };
clip_outline(&children[0]).unwrap().0.bounding_box()
};
let moved = || clipping_path(3, vec![fx("distort.transform", json!({"moveH": 60}))]);
let close = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5;
let want = Rect::new(100.0, 40.0, 220.0, 160.0);
// A group holding the member.
let b = evaluated(Node::group(NodeId(2), vec![Arc::new(moved())]));
assert!(close(b, want), "group: {b:?}");
// A compound path of the member, with a Transform of its own moving it 10 pt down.
let mut compound = Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(moved())], rule: vectorcraft_geom::FillRule::NonZero });
compound.appearance.effects.push(fx("distort.transform", json!({"moveV": 10})));
let b = evaluated(compound);
assert!(close(b, Rect::new(100.0, 50.0, 220.0, 170.0)), "compound: {b:?}");
}
#[test]
fn clipping_type_keeps_its_effect_where_other_type_stays_type() {
// Type moved 30 pt right by its Transform clips a red square, beside other type, in a group
// that a Transform moves 10 pt down: the export keeps the other type as type and outlines the
// clipping type, which still clips where the canvas clips.
use vectorcraft_doc::text::{CharStyle, TextObject};
let text = |id: u64, effects: Vec<Effect>| {
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut t = Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style))));
t.appearance.effects = effects;
t
};
let clipped = clip_group(2, text(3, vec![fx("distort.transform", json!({"moveH": 30}))]));
let mut outer = Node::group(NodeId(1), vec![Arc::new(clipped), Arc::new(text(5, vec![]))]);
outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 10})));
fn clip_of(n: &Node) -> Option<Rect> {
match &n.kind {
NodeKind::Group { children, clip: true } => Some(clip_outline(children.first()?)?.0.bounding_box()),
_ => n.children()?.iter().find_map(|c| clip_of(c)),
}
}
if vectorcraft_text::CRAFT_FONTS.is_empty() {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
return;
}
let plain = clip_outline(&text(3, vec![])).unwrap().0.bounding_box();
let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap()).unwrap();
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, outer).unwrap();
let baked = bake_document(&d).unwrap();
let mut exported = None;
for l in &baked.layers {
exported = exported.or_else(|| clip_of(l));
}
let exported = exported.unwrap();
assert!((canvas.x0 - plain.x0 - 30.0).abs() < 0.5 && (canvas.y0 - plain.y0 - 10.0).abs() < 0.5, "{canvas:?} from {plain:?}");
assert!((exported.x0 - canvas.x0).abs() < 0.5 && (exported.y0 - canvas.y0).abs() < 0.5, "exported {exported:?}, canvas {canvas:?}");
// The outlined clipping type keeps its black fill, which paints behind the clipped art.
let outlined = crate::reshape::clipping_text_outline(&text(3, vec![fx("distort.transform", json!({"moveH": 30}))])).unwrap();
fn fills(n: &Node) -> usize {
n.appearance.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Fill(f) if f.paint != Paint::None)).count()
+ n.children().into_iter().flatten().map(|c| fills(c)).sum::<usize>()
}
assert!(fills(&outlined) > 0, "{outlined:?}");
}
#[test]
fn a_painted_clipping_paths_gradient_follows_its_effect() {
// A clipping path filled with a gradient placed across it, moved 60 pt right by its Transform
// effect, in a clip group with an effect of its own: the gradient moves with the path.
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)];
let geom = GradientGeom { start: (50.0, 100.0).into(), end: (150.0, 100.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) }));
let mut clip = clipping_path(2, vec![fx("distort.transform", json!({"moveH": 60}))]);
clip.appearance = Appearance { effects: clip.appearance.effects.clone(), ..Appearance::basic(placed, Paint::None, 0.0) }.into();
let mut group = clip_group(1, clip);
group.appearance.effects.push(fx("path.offsetPath", json!({"offset": 0})));
let evaluated = crate::evaluate_container(None, &group).unwrap();
let NodeKind::Group { children, clip: true } = &evaluated.kind else { panic!("not a clip group") };
let Paint::Gradient(g) = children[0].appearance.fill_paint() else { panic!("no gradient") };
let start = g.geom.unwrap().start;
assert!((start.x - 110.0).abs() < 1e-6, "the gradient starts at the moved path's left edge: {start:?}");
}
#[test]
fn a_nested_clip_groups_hidden_clipping_path_keeps_its_effect() {
// A clip group used as a clipping path clips by its clipping path whether it is shown or not.
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
let mut hidden = clipping_path(4, vec![fx("distort.transform", json!({"moveH": -30}))]);
hidden.visible = false;
let outer = clip_group(1, clip_group(3, hidden));
d.insert(Some(layer), 0, outer.clone()).unwrap();
let NodeKind::Group { children, .. } = &outer.kind else { panic!("not a group") };
let b = bounds(&children[0]);
assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 120.0).abs() < 1e-6, "{b:?}");
assert_eq!(clip_outline(&children[0]), clip_outline(&baked_clip(&d)), "the canvas clips by the outline the export writes");
}
#[test]
fn a_fills_geometry_effect_shapes_the_clip_as_an_export_does() {
// The clipping path's fill has a Transform effect moving it 60 pt right; the path has none.
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
let mut clip = clipping_path(2, vec![]);
clip.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0).into();
for item in &mut clip.appearance.items {
if let vectorcraft_doc::AppearanceItem::Fill(f) = item {
f.effects.push(fx("distort.transform", json!({"moveH": 60})));
}
}
let group = clip_group(1, clip);
d.insert(Some(layer), 0, group.clone()).unwrap();
let NodeKind::Group { children, .. } = &group.kind else { panic!("not a group") };
let live = clip_outline(&children[0]).unwrap();
let exported = clip_outline(&baked_clip(&d)).unwrap();
assert_eq!(live.0.bounding_box(), exported.0.bounding_box(), "the canvas clips by the outline the export writes");
assert!((live.0.bounding_box().x1 - 210.0).abs() < 1e-6, "the moved fill reaches 210: {:?}", live.0.bounding_box());
// A path of a compound clipping path with a fill effect of its own stays part of the region.
let mut member = clipping_path(4, vec![]);
member.appearance = children[0].appearance.clone();
let other = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default());
let compound =
Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(member), Arc::new(other)], rule: vectorcraft_geom::FillRule::NonZero });
assert_eq!(bounds(&compound), Rect::new(0.0, 0.0, 150.0, 150.0));
}
#[test]
fn a_fills_geometry_effect_shapes_the_clip_before_the_effects_around_it() {
// Offset Path 10 on the fill of an unpainted clipping path, in a group that its Transform
// scales to 50 %: the region is the grown square at half size, 60 pt across, as it is when the
// clipping path is the member of a group that clips by its members.
let clip_with = |fill: Paint| {
let mut clip = clipping_path(3, vec![]);
clip.appearance = Appearance::basic(fill, Paint::None, 0.0).into();
for item in &mut clip.appearance.items {
if let vectorcraft_doc::AppearanceItem::Fill(f) = item {
f.effects.push(fx("path.offsetPath", json!({"offset": 10})));
}
}
clip
};
let scaled = |clip: Node| {
let mut outer = Node::group(NodeId(1), vec![Arc::new(clip_group(2, clip))]);
outer.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50})));
outer
};
fn first_clip(n: &Node) -> Option<Arc<Node>> {
match &n.kind {
NodeKind::Group { children, clip: true } => children.first().cloned(),
_ => n.children()?.iter().find_map(|c| first_clip(c)),
}
}
let clip_of = |n: &Node| clip_outline(&first_clip(n).unwrap()).unwrap().0.bounding_box();
let close = |r: Rect| (r.width() - 60.0).abs() < 0.5 && (r.height() - 60.0).abs() < 0.5;
let nested = clip_of(&crate::evaluate_container(None, &scaled(Node::group(NodeId(4), vec![Arc::new(clip_with(Paint::None))]))).unwrap());
assert!(close(nested), "nested: {nested:?}");
let outer = scaled(clip_with(Paint::None));
let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap());
assert!(close(canvas), "canvas: {canvas:?}");
let mut d = Document::new(400.0, 400.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, outer).unwrap();
let baked = bake_document(&d).unwrap();
let exported = baked.layers.iter().find_map(|l| first_clip(l)).map(|c| clip_outline(&c).unwrap().0.bounding_box()).unwrap();
assert!(close(exported), "exported: {exported:?}");
// A clipping path that paints its fill keeps it, a path with the fill's effect live.
let painted = first_clip(&crate::evaluate_container(None, &scaled(clip_with(Paint::solid(Color::BLACK)))).unwrap()).unwrap();
assert!(painted.clip_paint().fill.is_some(), "{painted:?}");
}
#[test]
fn a_baked_clipping_path_gives_its_pieces_ids_of_their_own() {
// An unpainted clipping path whose fill and stroke have Transform effects, in a clip group
// without effects: the export bakes it into a piece for each, every object with its own id.
let mut d = Document::new(400.0, 400.0);
let (group, path) = (d.alloc_id(), d.alloc_id());
let mut clip = clipping_path(path.0, vec![]);
clip.appearance = Appearance::basic(Paint::None, Paint::None, 1.0).into();
for item in &mut clip.appearance.items {
match item {
vectorcraft_doc::AppearanceItem::Fill(f) => f.effects.push(fx("distort.transform", json!({"moveH": 60}))),
vectorcraft_doc::AppearanceItem::Stroke(s) => s.effects.push(fx("distort.transform", json!({"moveV": 60}))),
}
}
let layer = d.layers[0].id;
d.insert(Some(layer), 0, clip_group(group.0, clip)).unwrap();
let baked = bake_document(&d).unwrap();
let clip = baked_clip(&d);
assert_eq!(clip.children().map(|c| c.len()), Some(2), "{clip:?}");
fn ids(n: &Node, out: &mut Vec<NodeId>) {
out.push(n.id);
for c in n.children().into_iter().flatten() {
ids(c, out);
}
}
let mut all = vec![];
for l in &baked.layers {
ids(l, &mut all);
}
let unique: std::collections::HashSet<_> = all.iter().collect();
assert_eq!(unique.len(), all.len(), "{all:?}");
}
#[test]
fn a_hidden_member_with_an_effect_adds_nothing() {
let shown = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 100.0, 100.0)), Appearance::default());
let mut hidden = clipping_path(4, vec![fx("distort.transform", json!({"moveH": 200}))]);
hidden.visible = false;
let group = Node::group(NodeId(2), vec![Arc::new(shown), Arc::new(hidden)]);
assert_eq!(bounds(&group), Rect::new(50.0, 50.0, 100.0, 100.0));
}
#[test]
fn raster_and_hidden_effects_leave_the_clip_as_it_is() {
let mut hidden = fx("distort.transform", json!({"moveH": 60}));
hidden.visible = false;
for effects in [vec![], vec![fx("blur.gaussian", json!({"radius": 10}))], vec![hidden]] {
assert_eq!(bounds(&clipping_path(1, effects)), Rect::new(50.0, 50.0, 150.0, 150.0));
}
}
+1 -1
View File
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand",
["Object", "Live Paint"],
None,
"{id?, ids?: [id…] (instead of id)} turn Live Paint groups into plain groups of their painted faces and edges → {ids}",
"{id?, ids?: [id…] (instead of id)} turn Live Paint groups into plain groups of the faces and edges that draw, with all their fill and stroke rows: a shown face or edge with an opacity above 0 and a shown fill or stroke row that has paint and an opacity above 0 (a stroke also a weight above 0), whichever row is on top → {ids}",
has_selection_or_ids,
lp_expand
),
+50 -17
View File
@@ -1,7 +1,7 @@
use super::{
Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point,
Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint,
json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, PathData, Point, Rect,
Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, json,
number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
};
/// `options` as stored on a Live Paint group: none for the defaults.
@@ -84,6 +84,35 @@ pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
}
/// What Merge carries from a face or edge to the part it makes again in its place, another path:
/// the appearance, visibility, opacity and blending mode. Gradients fitted to the old outline keep
/// that fit, and the strokes' pen pressure, tilt, bearing and rotation stay behind.
struct Carried {
appearance: Appearance,
visible: bool,
opacity: f32,
blend: vectorcraft_color::BlendMode,
}
impl Carried {
fn of(n: &Node) -> Self {
let mut appearance = (*n.appearance).clone().without_pressure();
if appearance.has_unplaced_gradient()
&& let Some(bounds) = n.geometric_bounds()
{
appearance.pin_gradients(bounds);
}
Self { appearance, visible: n.visible, opacity: n.opacity, blend: n.blend }
}
fn onto(self, n: &mut Node) {
n.appearance = self.appearance.into();
n.visible = self.visible;
n.opacity = self.opacity;
n.blend = self.blend;
}
}
/// Add the other `roots` (back → front) to the first Live Paint group among them, which keeps its
/// id, the paint of its faces and edges, and its own properties apart from the Gap Options, which
/// are merged; returns (group, faces, edges).
@@ -95,18 +124,16 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
check_live_paint_takes(d, roots)?;
let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
// Paint to carry over: (point inside face, fill) and (point on edge, stroke).
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
.iter()
.filter(|f| !f.appearance.fill_paint().is_none())
.filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint())))
.collect();
let keep_edges: Vec<(Point, Appearance)> = b::edges(&old)
// Paint to carry over: (point inside face, paint) for faces that draw, and (point on edge,
// paint) for every edge.
let keep_faces: Vec<(Point, Carried)> =
b::faces(&old).iter().filter(|f| draws(f)).filter_map(|f| Some((po::interior_point(f.path_data()?)?, Carried::of(f)))).collect();
let keep_edges: Vec<(Point, Carried)> = b::edges(&old)
.iter()
.filter_map(|e| {
let pd = e.path_data()?;
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone()))
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), Carried::of(e)))
})
.collect();
let mut sources = source_nodes(&old);
@@ -126,20 +153,20 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
*ch = children;
}
g.extras_mut().live_paint_gaps = stored_gap_options(options);
for (pt, paint) in keep_faces {
for (pt, carried) in keep_faces {
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
&& let Some(ch) = g.children_mut()
&& let Some(f) = ch.iter_mut().find(|c| c.id == fid)
{
Arc::make_mut(f).appearance.set_fill(paint);
carried.onto(Arc::make_mut(f));
}
}
for (pt, app) in keep_edges {
for (pt, carried) in keep_edges {
if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id)
&& let Some(ch) = g.children_mut()
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
{
Arc::make_mut(e).appearance = app.into();
carried.onto(Arc::make_mut(e));
}
}
let mut merged = Beside::replacing(d, roots);
@@ -191,6 +218,13 @@ pub(super) fn lp_release(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&ids) }))
}
/// Does face or edge `n` draw on the canvas: it is shown, its opacity is above 0, and a fill or
/// stroke row of its appearance draws (see [`Appearance::has_drawn_fill`] and
/// [`Appearance::has_drawn_stroke`])?
fn draws(n: &Node) -> bool {
n.visible && n.opacity > 0.0 && (n.appearance.has_drawn_fill() || n.appearance.has_drawn_stroke())
}
pub(super) fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
let targets = live_targets(s, p)?;
s.edit("Expand Live Paint", |d, sel| {
@@ -205,8 +239,7 @@ pub(super) fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(ch) = n.children_mut() {
ch.retain(|c| match c.name.as_deref() {
Some(SOURCES_NAME) => false,
Some(FACE_NAME) => !c.appearance.fill_paint().is_none(),
Some(EDGE_NAME) => !c.appearance.stroke_paint().is_none() && c.appearance.stroke_width() > 0.0,
Some(FACE_NAME | EDGE_NAME) => draws(c),
_ => true,
});
}
@@ -478,6 +478,75 @@ fn an_opened_file_measures_its_type_in_its_own_composite_fonts() {
assert_eq!(actual.cached_bounds, Some(expected));
}
/// The label of dimension `id` in `doc`.
fn label(doc: &vectorcraft_doc::Document, id: u64) -> vectorcraft_doc::TextObject {
let node = doc.node(NodeId(id)).unwrap();
node.children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).unwrap()
}
/// The center of label `t`'s measured box on the page.
fn label_center(t: &vectorcraft_doc::TextObject) -> vectorcraft_geom::Point {
t.xf * t.cached_bounds.unwrap().center()
}
/// Whether every character of label `t` is filled in CMYK.
fn label_in_cmyk(t: &vectorcraft_doc::TextObject) -> bool {
!t.runs.is_empty() && t.runs.iter().all(|r| matches!(r.style.fill.color(), Some(vectorcraft_color::Color::Cmyk { .. })))
}
/// The native file of a CMYK document whose composite font `name` sets Roman at full size, with a
/// dimension whose label is set in it and whose label's characters are saved in RGB, so opening
/// the file draws the dimension again → (the file, the dimension's id, the center of its label as
/// drawn in the document's own composite font).
fn dimension_file(name: &str) -> (Vec<u8>, u64, vectorcraft_geom::Point) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400, "colorMode": "cmyk"})).unwrap();
let mut full = font(name);
full["parts"]["roman"]["size"] = json!(100);
s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap();
let line = s.execute("shape.line", &json!({"x1": 100, "y1": 100, "x2": 172, "y2": 100})).unwrap()["id"].as_u64().unwrap();
let id = s
.execute(
"dimension.create",
&json!({"source": {"kind": "segment", "edge": {"node": line, "subpath": 0, "segment": 0}},
"options": {"units": "in", "precision": 2, "fontFamily": name, "fontSize": 20}}),
)
.unwrap()["id"]
.as_u64()
.unwrap();
let mut doc = (*s.doc().unwrap().doc).clone();
let drawn = label(&doc, id);
assert!(label_in_cmyk(&drawn));
let center = label_center(&drawn);
let mut label_id = None;
doc.node(NodeId(id)).unwrap().walk(&mut |n| {
if matches!(n.kind, NodeKind::Text(_)) {
label_id = Some(n.id);
}
});
if let Some(NodeKind::Text(t)) = doc.node_mut(label_id.unwrap()).map(|n| &mut n.kind) {
for r in &mut t.runs {
r.style.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 0));
}
}
(vectorcraft_format::save_file(&doc), id, center)
}
/// A file whose dimensions are drawn again when it opens (their art is not in the document's color
/// mode) draws their labels in its own composite fonts, not in those of the front document, which
/// defines the label's font name with Roman at half size: each label stays centered on its anchor.
#[test]
fn an_opened_file_draws_its_dimension_labels_in_its_own_composite_fonts() {
let (bytes, id, center) = dimension_file("CF Dimension");
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Dimension")]})).unwrap();
let file = crate::cmd::fileio::read_for_open("dimension.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
crate::cmd::fileio::open_read(&mut s, file).unwrap();
let opened = label(&s.doc().unwrap().doc, id);
assert!(label_in_cmyk(&opened));
assert!((label_center(&opened) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&opened));
}
/// Threaded type read from a file flows through its frames in the file's own composite fonts, when
/// no open document defines them.
#[test]
@@ -536,6 +605,40 @@ fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() {
assert_eq!(actual.cached_bounds, Some(expected));
}
/// A document replaced in front (File › Revert) whose dimensions are drawn again draws their labels
/// in its new composite fonts, not in those it had before.
#[test]
fn a_document_replaced_in_front_draws_its_dimension_labels_in_its_own_composite_fonts() {
let (bytes, id, center) = dimension_file("CF Reverted");
let mut s = session();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Reverted")]})).unwrap();
assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
let replaced = label(&s.doc().unwrap().doc, id);
assert!(label_in_cmyk(&replaced));
assert!((label_center(&replaced) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&replaced));
}
/// A document replaced in the back draws its dimension labels in its own composite fonts, while the
/// front document keeps its definition of the same name registered; the labels stay where they are
/// when the document comes to the front.
#[test]
fn a_document_replaced_in_the_back_draws_its_dimension_labels_in_its_own_composite_fonts() {
let (bytes, id, center) = dimension_file("CF Back Dimension");
let mut s = session();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Back Dimension")]})).unwrap();
assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
assert_eq!(s.active_index(), Some(1));
let front = vectorcraft_text::FontDb::global().composite_font("CF Back Dimension").unwrap();
assert_eq!(front.parts.get(vectorcraft_doc::CharClass::Roman).size, 50.0);
let replaced = label(&s.documents()[0].doc, id);
assert!(label_in_cmyk(&replaced));
assert!((label_center(&replaced) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&replaced));
assert!(s.set_active(0));
let shown = label(&s.doc().unwrap().doc, id);
assert!((label_center(&shown) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&shown));
}
/// The type of document `index` with id `id`, wherever its tab is.
fn text_in(s: &Session, index: usize, id: u64) -> vectorcraft_doc::TextObject {
match &s.documents()[index].doc.node(NodeId(id)).unwrap().kind {
+6 -1
View File
@@ -495,7 +495,8 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
/// Redraw the dimensions of an opened document whose art isn't in its colour mode: files saved
/// before dimensions followed the colour mode have RGB dimensions in CMYK documents. A dimension
/// that can't be redrawn keeps the art it has.
/// that can't be redrawn keeps the art it has. The labels are measured in the document's own
/// composite fonts, also when another document's definitions of the same names are registered.
pub(crate) fn conform(doc: &mut Document) {
if !doc.dimensions {
return;
@@ -521,6 +522,10 @@ pub(crate) fn conform(doc: &mut Document) {
}
}
});
if stale.is_empty() {
return;
}
let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts);
for (id, d) in stale {
// An error leaves this dimension as it was; the others are still redrawn.
let _ = replace(doc, id, d);
+3 -71
View File
@@ -344,44 +344,9 @@ fn spot_swatches(d: &Document) -> HashSet<&str> {
first.into_iter().filter(|&(name, spot)| spot && name != vectorcraft_color::swatch::REGISTRATION).map(|(name, _)| name).collect()
}
/// Which of gradient `g`'s stops color some part of it that draws, by the stretches
/// [`vectorcraft_color::Gradient::sample`] mixes in, for stops in any order: the pair of stop `k`
/// and stop `k + 1` covers the offsets above every offset of stops `0..=k` up to stop `k + 1`'s,
/// and both its stops count when that stretch has positive width and either stop is above opacity
/// 0. The first stop also counts when it is above opacity 0, since it colors the offsets up to its
/// own, and so does the last stop, since it colors the offsets above every stop's. Any other stop,
/// opaque or not, counts only through a pair: a stop out of order whose pairs have no width
/// (another stop before it reaches past both ends) colors nothing. A freeform gradient draws from
/// its points, so each of its stops counts while any stop is above opacity 0.
fn drawn_stops(g: &vectorcraft_color::Gradient) -> Vec<bool> {
let stops = &g.stops;
if g.kind == vectorcraft_color::GradientKind::Freeform {
let any = stops.iter().any(|s| s.opacity > 0.0);
return vec![any; stops.len()];
}
let mut drawn = vec![false; stops.len()];
if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) {
*d = first.opacity > 0.0;
}
if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) {
*d |= last.opacity > 0.0;
}
// `f32::max` skips a NaN offset, which the sampler never picks either.
let mut reach = f32::NEG_INFINITY;
for k in 0..stops.len().saturating_sub(1) {
reach = reach.max(stops[k].offset);
let (a, b) = (&stops[k], &stops[k + 1]);
if b.offset > reach && (a.opacity > 0.0 || b.opacity > 0.0) {
drawn[k] = true;
drawn[k + 1] = true;
}
}
drawn
}
/// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is
/// linked to, or those a gradient's drawn stops ([`drawn_stops`]) are linked to, each once, in the
/// order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a
/// linked to, or those a gradient's drawn stops ([`vectorcraft_color::Gradient::drawn_stops`]) are
/// linked to, each once, in the order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a
/// gradient at an opacity of 0 prints none, and a color that only looks like a spot color is not
/// linked to one.
fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> {
@@ -398,7 +363,7 @@ fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> {
match p {
Paint::Solid { swatch, tint, .. } => add(swatch, *tint),
Paint::Gradient(g) => {
let drawn = drawn_stops(&g.gradient);
let drawn = g.gradient.drawn_stops();
g.gradient.stops.iter().zip(drawn).filter(|&(_, d)| d).for_each(|(s, _)| add(&s.swatch, s.tint));
}
_ => {}
@@ -1662,39 +1627,6 @@ mod tests {
assert!(!["Far", "Edge", "Seam"].iter().any(|n| text.contains(n)), "{text}");
}
/// A gradient's stops print where they color a stretch that draws, for stops in any order: the
/// sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it, so an
/// out-of-order stop's pair can have no width.
#[test]
fn drawn_stops_follow_the_sampled_stretches() {
use vectorcraft_color::{Color, Gradient, GradientKind, GradientStop};
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) };
let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops);
// In order: the clear stop next to the opaque one, not the one after it.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)])), vec![true, true, false]);
// Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with
// the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)])), vec![false, false, true, true]);
// An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the
// pairs next to the stop at 0.3 have no width and it colors nothing.
let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]);
assert_eq!(drawn_stops(&out_of_order(0.0)), vec![false; 5]);
// With the stop at 0.8 opaque, the pairs next to it with width are drawn.
assert_eq!(drawn_stops(&out_of_order(1.0)), vec![true, true, false, false, false]);
// The last stop colors the offsets above every stop's, even when it is out of order.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)])), vec![false, false, true]);
// The first stop colors the offsets up to its own.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)])), vec![true, false, false]);
// One stop, every stop clear, and no stops at all.
assert_eq!(drawn_stops(&g(vec![stop(0.5, 1.0)])), vec![true]);
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(1.0, 0.0)])), vec![false, false]);
assert!(drawn_stops(&g(vec![])).is_empty());
// A freeform gradient draws from its points: its stops count while any is above opacity 0.
let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]);
free.kind = GradientKind::Freeform;
assert_eq!(drawn_stops(&free), vec![true, true, true]);
}
/// Brushes, Spot Color Objects, Pattern Objects and Gradient Objects leave out a hidden object,
/// an object at an opacity of 0 and the objects inside a hidden layer or a group at an opacity of
/// 0, with Selection Only too: their fills, strokes, characters and colorized pixels paint no
+16
View File
@@ -186,6 +186,22 @@ fn planar_tiffs_read_as_their_chunky_versions() {
}
}
/// A 16-bit CMYK TIFF with alpha places with its alpha rounded from 16 bits, planar as chunky.
#[test]
fn a_16_bit_cmyk_tiff_with_alpha_places_at_16_bits() {
let none = vec![0u16; 6];
let cyan = vec![129u16, 0, 300, 0, 128, 65535];
let alpha = vec![65535u16, 129, 386, 0, 256, 65535];
for extra in [1u16, 2] {
let channels = [cyan.clone(), none.clone(), none.clone(), none.clone(), alpha.clone()];
let chunky = kept(&format!("extra samples {extra}, chunky"), &hand_tiff(5, 16, &[extra], false, &channels));
let planar = kept(&format!("extra samples {extra}, planar"), &hand_tiff(5, 16, &[extra], true, &channels));
assert_eq!(planar, chunky, "extra samples {extra}: planar reads as chunky");
let alphas: Vec<u8> = chunky.1.iter().skip(3).step_by(4).copied().collect();
assert_eq!(alphas, [255, 1, 2, 0, 1, 255], "extra samples {extra}: the alpha, rounded from 16 bits");
}
}
/// A planar TIFF whose header claims more strips than the decoder can count (here 2^31 + 1 rows
/// a strip each, times 5 planes) is refused as too large to draw, without a panic.
#[test]
+104 -27
View File
@@ -667,6 +667,16 @@ enum ClipTo {
File(PathData),
/// It goes into a new clip group of this path, inside the clip group that holds it.
Nest(PathData),
/// Placement Options clip it to `placement` while its DCS file draws it through `file`: it is
/// clipped to `file` as [`ClipTo::File`] clips it, and the clip group holding it is clipped to
/// `placement` as [`ClipTo::Path`] clips an object. With no `file` (the new file lacks the
/// clipping path of the clip group holding it), that group stays as [`ClipTo::Off`] leaves it,
/// clipped to `placement`.
Both { file: Option<PathData>, placement: PathData },
/// Placement Options clip it to `placement` while its DCS file draws it through `file`, and the
/// clip group holding just it is not its old file's: that clip group's clipping path becomes
/// `placement`, and it goes into a new clip group of `file` inside it.
BothInside { file: PathData, placement: PathData },
/// The clipping path of the clip group holding just it goes (a DCS file's clipping path that
/// its new file lacks): the group stays as a plain group of it, with its id, name, visibility
/// and transparency.
@@ -702,10 +712,10 @@ fn same_path(a: &PathData, b: &PathData) -> bool {
}
/// The clip an image placed with `old_xf` takes when it shows `img`, read from a file, placed with
/// `new_xf`: Placement Options' clip `clip` when they clip it, else the clipping path a DCS file
/// draws `img` through. When neither clips it, a clip group holding just it whose clipping path
/// `held` is its old DCS file's (`old_clip`, in the old pixels, under the nonzero rule its clip
/// groups get) goes.
/// `new_xf`: Placement Options' clip `clip` when they clip it, and the clipping path a DCS file
/// draws `img` through, the file's clip group inside the placement's. When neither clips it, a clip
/// group holding just it whose clipping path `held` is its old DCS file's (`old_clip`, in the old
/// pixels, under the nonzero rule its clip groups get) goes.
fn clip_to(
old_xf: Affine,
old_clip: Option<&PathData>,
@@ -714,16 +724,22 @@ fn clip_to(
new_xf: Affine,
held: Option<(&PathData, FillRule)>,
) -> ClipTo {
if let Some(clip) = clip {
return ClipTo::Path(clip_shape(clip));
}
// Whether the clip group holding just the image is the old DCS file's: a clipping path whose
// fill rule was changed in the document is not.
let file_held = match (old_clip, held) {
(Some(old), Some((held, rule))) => rule == FillRule::NonZero && same_path(&old.transformed(old_xf), held),
_ => false,
};
match img.clip.as_ref().map(|path| path.transformed(new_xf)) {
let file = img.clip.as_ref().map(|path| path.transformed(new_xf));
if let Some(clip) = clip {
let placement = clip_shape(clip);
return match file {
Some(file) if held.is_some() && !file_held => ClipTo::BothInside { file, placement },
file if file.is_some() || file_held => ClipTo::Both { file, placement },
_ => ClipTo::Path(placement),
};
}
match file {
// A clipping mask made in the document stays, with the file's clip group inside it.
Some(path) if held.is_some() && !file_held => ClipTo::Nest(path),
Some(path) => ClipTo::File(path),
@@ -752,7 +768,8 @@ impl Nested<'_> {
let first = d.peek_next_id();
self.next = first;
for l in d.layers.iter_mut() {
self.update(l, false);
// Nothing is picked in the layers themselves, so no layer is clipped.
let _ = self.update(l, false);
}
for root in d.symbols.iter_mut().map(|s| &mut s.art).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) {
self.root(root);
@@ -769,20 +786,30 @@ impl Nested<'_> {
/// mask art).
fn root(&mut self, n: &mut Arc<Node>) {
if !(self.pick)(n) {
return self.update(n, true);
if let Some(placement) = self.update(n, true) {
*n = Arc::new(self.clip_group(Arc::clone(n), placement));
}
return;
}
let (new, clip) = (self.make)(n, None);
let mut new = Arc::new(new);
self.update(&mut new, true);
let _ = self.update(&mut new, true);
*n = match clip {
ClipTo::Path(path) | ClipTo::File(path) | ClipTo::Nest(path) => Arc::new(self.clip_group(new, path)),
ClipTo::Both { file, placement } => self.clip_both(new, file, placement),
ClipTo::BothInside { file, placement } => self.clip_both(new, Some(file), placement),
ClipTo::Keep | ClipTo::Off => new,
};
}
/// `n` (outside the layer tree when `nested`) with the picked objects under it remade; it stays
/// the same node when none is there, and only the nodes on the way to one are copied.
fn update(&mut self, n: &mut Arc<Node>, nested: bool) {
/// the same node when none is there, and only the nodes on the way to one are copied. The
/// result is Placement Options' clip that `n` itself, now the clip group of a DCS file's
/// clipping path holding a picked object (or the plain group [`ClipTo::Off`] leaves), takes:
/// the clipping path of the clip group holding just `n` becomes it, else `n` goes into a new
/// clip group of it.
#[must_use]
fn update(&mut self, n: &mut Arc<Node>, nested: bool) -> Option<PathData> {
let mask = n.extras().mask.as_ref().and_then(|m| {
let mut art = Arc::clone(&m.art);
self.root(&mut art);
@@ -794,6 +821,8 @@ impl Nested<'_> {
// The clipping path and object of a plain group holding just the object, which becomes a
// clip group again.
let mut reclipped = None;
// Placement Options' clip for `n` itself.
let mut around = None;
if let Some(children) = n.children() {
// A clip group holding just the object: its clipping path changes.
let clip_path = match (&n.kind, &children[..]) {
@@ -804,9 +833,11 @@ impl Nested<'_> {
for (i, c) in children.iter().enumerate() {
if !(nested && (self.pick)(c)) {
let mut new = Arc::clone(c);
self.update(&mut new, nested);
if !Arc::ptr_eq(&new, c) {
slots.push((i, new));
match (self.update(&mut new, nested), clip_path) {
(None, _) if Arc::ptr_eq(&new, c) => {}
(None, _) => slots.push((i, new)),
(Some(placement), Some(path)) if i == 1 => slots.extend([(0, with_clip(path, placement)), (1, new)]),
(Some(placement), _) => slots.push((i, Arc::new(self.clip_group(new, placement)))),
}
continue;
}
@@ -816,16 +847,10 @@ impl Nested<'_> {
});
let (new, clip) = (self.make)(c, held);
let mut new = Arc::new(new);
self.update(&mut new, true);
let _ = self.update(&mut new, true);
match (clip, clip_path) {
(ClipTo::Keep, _) => slots.push((i, new)),
(ClipTo::Path(clip) | ClipTo::File(clip), Some(path)) if i == 1 => {
let mut path = Node::clone(path);
if let NodeKind::Path { path: p, .. } = &mut path.kind {
*p = clip;
}
slots.extend([(0, Arc::new(path)), (1, new)]);
}
(ClipTo::Path(clip) | ClipTo::File(clip), Some(path)) if i == 1 => slots.extend([(0, with_clip(path, clip)), (1, new)]),
(ClipTo::File(clip), None) if plain => reclipped = Some((clip, new)),
(ClipTo::Path(clip) | ClipTo::File(clip) | ClipTo::Nest(clip), _) => {
let group = self.clip_group(new, clip);
@@ -833,11 +858,30 @@ impl Nested<'_> {
}
(ClipTo::Off, Some(_)) if i == 1 => declipped = Some(new),
(ClipTo::Off, _) => slots.push((i, new)),
// The file's clip group stays `n`, and the placement's clip goes around it.
(ClipTo::Both { file: Some(file), placement }, Some(path)) if i == 1 => {
slots.extend([(0, with_clip(path, file)), (1, new)]);
around = Some(placement);
}
(ClipTo::Both { file: Some(file), placement }, None) if plain => {
reclipped = Some((file, new));
around = Some(placement);
}
(ClipTo::Both { file: None, placement }, Some(_)) if i == 1 => {
declipped = Some(new);
around = Some(placement);
}
(ClipTo::BothInside { file, placement }, Some(path)) if i == 1 => {
let group = self.clip_group(new, file);
slots.extend([(0, with_clip(path, placement)), (1, Arc::new(group))]);
}
(ClipTo::Both { file, placement }, _) => slots.push((i, self.clip_both(new, file, placement))),
(ClipTo::BothInside { file, placement }, _) => slots.push((i, self.clip_both(new, Some(file), placement))),
}
}
}
if mask.is_none() && slots.is_empty() && declipped.is_none() && reclipped.is_none() {
return;
return None;
}
let reclipped = reclipped.map(|(clip, object)| (Arc::new(self.clipping_path(clip)), object));
let n = Arc::make_mut(n);
@@ -849,14 +893,14 @@ impl Nested<'_> {
if let NodeKind::Group { children, clip } = &mut n.kind {
(*children, *clip) = (vec![path, object], true);
}
return;
return around;
}
// A clip group that stays as a plain group of just the object.
if let Some(object) = declipped {
if let NodeKind::Group { children, clip } = &mut n.kind {
(*children, *clip) = (vec![object], false);
}
return;
return around;
}
if let Some(children) = n.children_mut() {
for (i, c) in slots {
@@ -865,6 +909,17 @@ impl Nested<'_> {
}
}
}
around
}
/// A clip group of `placement` holding `object`, inside a clip group of `file` when there is a
/// `file` ([`ClipTo::Both`] for an object no clip group holds yet).
fn clip_both(&mut self, object: Arc<Node>, file: Option<PathData>, placement: PathData) -> Arc<Node> {
let object = match file {
Some(file) => Arc::new(self.clip_group(object, file)),
None => object,
};
Arc::new(self.clip_group(object, placement))
}
/// A clip group of `object` clipped to `clip`, as [`clip_image_to`] makes one in the layers.
@@ -1035,6 +1090,15 @@ fn clip_file_to(d: &mut Document, id: NodeId, path: PathData) -> Result<()> {
Ok(())
}
/// Clipping path `path` (a node) with its path changed to `clip`.
fn with_clip(path: &Node, clip: PathData) -> Arc<Node> {
let mut path = Node::clone(path);
if let NodeKind::Path { path: p, .. } = &mut path.kind {
*p = clip;
}
Arc::new(path)
}
/// A clipping path `id` of `path`, unpainted.
fn clipping_path(id: NodeId, path: PathData) -> Node {
let none = vectorcraft_color::Paint::None;
@@ -1130,6 +1194,19 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio
ClipTo::File(path) => clip_file_to(d, id, path),
ClipTo::Nest(path) => new_clip_group(d, id, path),
ClipTo::Off => unclip(d, id),
ClipTo::Both { file, placement } => {
match file {
Some(file) => clip_file_to(d, id, file)?,
None => unclip(d, id)?,
}
// The file's clip group (or the plain group left of it) takes the placement's clip.
let group = d.parent_of(id).ok_or(EngineError::NoNode(id))?;
clip_image_to(d, group, placement)
}
ClipTo::BothInside { file, placement } => {
clip_image_to(d, id, placement)?;
new_clip_group(d, id, file)
}
})
}
+15 -2
View File
@@ -149,9 +149,22 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
};
let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0)));
let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0");
let radius = number("radius", |r| r >= 0.0, "a radius of 0 or more")?;
// Saved steps ([`Session::replay`]) can hold values a direct call refuses, recorded before these
// checks: a negative radius is 0, a whole side count of 0 or more is brought into 3 to 1000, and
// a fractional or negative one is ignored. A whole number written as 5.0 counts as 5, as in a
// direct call.
let replaying = s.replaying();
let radius = if replaying {
number("radius", |_| true, "a number")?.map(|r| r.max(0.0))
} else {
number("radius", |r| r >= 0.0, "a radius of 0 or more")?
};
// A polygon's side count: a whole number from 3 to 1000.
let sides = number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64);
let sides = if replaying {
number("sides", |_| true, "a number")?.filter(|n| n.fract() == 0.0 && *n >= 0.0).map(|n| n.min(1000.0) as u64)
} else {
number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64)
};
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
+136 -1
View File
@@ -13,7 +13,7 @@ use std::sync::{Arc, LazyLock};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Symbol, SymbolNineSlice};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, Rect, SubPath, shapes};
use super::brushsym::{CURRENT_SYMBOL, natural_size, normalize, place_xf, set_natural_size};
use super::clipboard::Clipboard;
@@ -160,6 +160,7 @@ pub const BUILTIN: &[BuiltinSymbolLibrary] = &[
BuiltinSymbolLibrary { id: "map-markers", name: "Map Markers", make: map_markers },
BuiltinSymbolLibrary { id: "inventory-icons", name: "Inventory Icons", make: inventory_icons },
BuiltinSymbolLibrary { id: "skill-icons", name: "Skill Icons", make: skill_icons },
BuiltinSymbolLibrary { id: "weather-icons", name: "Weather Icons", make: weather_icons },
];
/// Built-in library `id`, made once.
@@ -358,6 +359,140 @@ fn skill_icons() -> SymbolLibrary {
SymbolLibrary { name: "Skill Icons".into(), doc }
}
/// Weather Icons: sun, moon, cloud, rain, thunder and snow for weather/status art (original, no 9-slice).
fn weather_icons() -> SymbolLibrary {
// 1. Sun: Central circular disk with radial rays.
let mut sun_subpaths = Vec::new();
sun_subpaths.extend(shapes::ellipse(Rect::new(-8.5, -8.5, 8.5, 8.5)).subpaths);
for i in 0..8 {
let angle = (i as f64) * std::f64::consts::PI / 4.0;
let (sin, cos) = angle.sin_cos();
let r_in = 11.5;
let r_out = 18.5;
let w = 1.5;
let p1 = Point::new(r_in * cos - w * sin, r_in * sin + w * cos);
let tip = Point::new(r_out * cos, r_out * sin);
let p2 = Point::new(r_in * cos + w * sin, r_in * sin - w * cos);
sun_subpaths.push(SubPath::polyline(&[p1, tip, p2], true));
}
let sun = PathData::new(sun_subpaths);
// 2. Moon: Crescent moon shape.
let moon_anchors = vec![
Anchor { p: Point::new(3.0, -17.0), h_in: Point::new(-2.0, -10.0), h_out: Point::new(-8.0, -17.0), kind: AnchorKind::Corner },
Anchor { p: Point::new(-16.0, 0.0), h_in: Point::new(-16.0, -9.0), h_out: Point::new(-16.0, 9.0), kind: AnchorKind::Smooth },
Anchor { p: Point::new(5.0, 16.0), h_in: Point::new(-6.0, 16.0), h_out: Point::new(0.0, 8.0), kind: AnchorKind::Corner },
Anchor { p: Point::new(-4.0, -1.0), h_in: Point::new(-4.0, 6.0), h_out: Point::new(-4.0, -8.0), kind: AnchorKind::Smooth },
];
let moon = PathData::single(SubPath { anchors: moon_anchors, closed: true });
// Helper for cloud contour
let cloud_subpath = |offset_y: f64, scale: f64| -> SubPath {
let mut pts = Vec::new();
let base_y = 8.0 * scale + offset_y;
pts.push(Point::new(9.0 * scale, base_y));
pts.push(Point::new(-9.0 * scale, base_y));
let c1 = Point::new(-9.0 * scale, 2.0 * scale + offset_y);
let r1 = 6.0 * scale;
let steps_lobe = 8;
for i in 1..=steps_lobe {
let a = std::f64::consts::FRAC_PI_2 + (i as f64) * std::f64::consts::PI / (steps_lobe as f64);
pts.push(Point::new(c1.x + r1 * a.cos(), c1.y + r1 * a.sin()));
}
let c2 = Point::new(0.0, -3.0 * scale + offset_y);
let r2 = 9.0 * scale;
let a_start = -2.973;
let a_end = -0.169;
let steps_top = 10;
for i in 1..=steps_top {
let a = a_start + (i as f64) * (a_end - a_start) / (steps_top as f64);
pts.push(Point::new(c2.x + r2 * a.cos(), c2.y + r2 * a.sin()));
}
let c3 = Point::new(9.0 * scale, 2.0 * scale + offset_y);
let r3 = 6.0 * scale;
for i in 1..steps_lobe {
let a = -std::f64::consts::FRAC_PI_2 + (i as f64) * std::f64::consts::PI / (steps_lobe as f64);
pts.push(Point::new(c3.x + r3 * a.cos(), c3.y + r3 * a.sin()));
}
SubPath::polyline(&pts, true)
};
// 3. Cloud: Merged circular lobes with a flat base.
let cloud = PathData::single(cloud_subpath(0.0, 1.0));
// 4. Rain: Cloud outline with angled falling droplet/dash lines.
let mut rain_subpaths = vec![cloud_subpath(-4.0, 0.9)];
let drops = [(-6.0, 6.0), (0.0, 8.0), (6.0, 6.0)];
for &(x0, y0) in &drops {
let (dx, dy) = (-2.0, 6.0);
let w = 1.0;
let d = [Point::new(x0 + w, y0), Point::new(x0 + dx + w, y0 + dy), Point::new(x0 + dx - w, y0 + dy), Point::new(x0 - w, y0)];
rain_subpaths.push(SubPath::polyline(&d, true));
}
let rain = PathData::new(rain_subpaths);
// 5. Thunder: Angular zigzag lightning bolt polygon.
let bolt = [(3.0, -18.0), (-8.0, -1.0), (-1.0, -1.0), (-5.0, 18.0), (8.0, 1.0), (1.0, 1.0)];
let bolt_pts: Vec<Point> = bolt.iter().map(|&(x, y)| Point::new(x, y)).collect();
let thunder = PathData::single(SubPath::polyline(&bolt_pts, true));
// 6. Snow: Symmetrical snowflake with hexagonal cross and branching ticks.
let mut snow_pts = Vec::new();
let r_hub = 3.5;
for k in 0..6 {
let angle = (k as f64) * std::f64::consts::PI / 3.0;
let (u_sin, u_cos) = angle.sin_cos();
let u = Point::new(u_cos, u_sin);
let v = Point::new(-u_sin, u_cos);
let to_pt = |du: f64, dv: f64| Point::new(du * u.x + dv * v.x, du * u.y + dv * v.y);
let valley_angle = angle - std::f64::consts::PI / 6.0;
snow_pts.push(Point::new(r_hub * valley_angle.cos(), r_hub * valley_angle.sin()));
snow_pts.push(to_pt(5.0, -1.0));
snow_pts.push(to_pt(8.5, -1.0));
snow_pts.push(to_pt(10.0, -3.0));
snow_pts.push(to_pt(11.5, -1.0));
snow_pts.push(to_pt(15.5, -1.0));
snow_pts.push(to_pt(15.5, -2.5));
snow_pts.push(to_pt(18.0, 0.0));
snow_pts.push(to_pt(15.5, 2.5));
snow_pts.push(to_pt(15.5, 1.0));
snow_pts.push(to_pt(11.5, 1.0));
snow_pts.push(to_pt(10.0, 3.0));
snow_pts.push(to_pt(8.5, 1.0));
snow_pts.push(to_pt(5.0, 1.0));
}
let snow = PathData::single(SubPath::polyline(&snow_pts, true));
let icons = [
("Sun", sun, "#f39c12"),
("Moon", moon, "#5c6bc0"),
("Cloud", cloud, "#78909c"),
("Rain", rain, "#3498db"),
("Thunder", thunder, "#f1c40f"),
("Snow", snow, "#29b6f6"),
];
let mut doc = Document::new(100.0, 100.0);
for (name, path, hex) in icons {
let fill = Color::from_hex(hex).unwrap_or(Color::BLACK);
let edge = fill.lerp(&Color::BLACK, 0.35);
let id = doc.alloc_id();
let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25));
let Ok((art, size, _)) = normalize(art) else { continue };
doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) });
set_natural_size(&mut doc, name, Some(size));
}
doc.title = "Weather Icons".into();
SymbolLibrary { name: "Weather Icons".into(), doc }
}
/// An instance of symbol `name` (what the resource gathering follows).
fn instance(name: &str) -> Node {
Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY })
+186
View File
@@ -2947,6 +2947,192 @@ fn live_paint_release_and_expand() {
assert!((area(&s, a) - 10000.0).abs() < 1.0);
}
/// Set the appearance rows of the face of Live Paint group `g` at `face` (or the edge nearest
/// `edge`) to `items`, and give it `opacity`; returns that child's id.
fn set_live_part(s: &mut Session, g: NodeId, face: Option<(f64, f64)>, edge: Option<(f64, f64)>, items: Vec<AppearanceItem>, opacity: f32) -> NodeId {
use vectorcraft_tools::builder::{edge_near, face_at};
let n = node(s, g);
let part = match (face, edge) {
(Some((x, y)), _) => face_at(&n, Point::new(x, y)),
(_, Some((x, y))) => edge_near(&n, Point::new(x, y), 0.5),
_ => None,
};
let id = part.unwrap().id;
s.edit("t", |d, _| {
let c = d.node_mut(id).unwrap();
c.appearance.items = items;
c.opacity = opacity;
Ok(())
})
.unwrap();
id
}
/// Expand keeps a face or edge when a fill or stroke row draws it, whatever row is on top: a
/// painted lower row under an unpainted top row is kept, and a part whose only painted row is
/// hidden, transparent or 0 pt wide, or whose own opacity is 0, is dropped.
#[test]
fn live_paint_expand_keeps_the_faces_and_edges_that_draw() {
use vectorcraft_doc::{FillLayer, StrokeLayer};
let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p));
let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w));
let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let hidden = |mut i: AppearanceItem| {
match &mut i {
AppearanceItem::Fill(f) => f.visible = false,
AppearanceItem::Stroke(st) => st.visible = false,
}
i
};
let clear = |mut i: AppearanceItem| {
match &mut i {
AppearanceItem::Fill(f) => f.opacity = 0.0,
AppearanceItem::Stroke(st) => st.opacity = 0.0,
}
i
};
let mut s = session();
let g = live(&mut s);
let lower_fill = set_live_part(&mut s, g, Some((25.0, 50.0)), None, vec![fill(red()), fill(Paint::None)], 1.0);
let hidden_fill = set_live_part(&mut s, g, Some((125.0, 50.0)), None, vec![hidden(fill(red()))], 1.0);
let clear_fill = set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![clear(fill(red()))], 1.0);
let lower_stroke = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![stroke(red(), 2.0), stroke(Paint::None, 1.0)], 1.0);
let hidden_stroke = set_live_part(&mut s, g, None, Some((150.0, 50.0)), vec![hidden(stroke(red(), 2.0))], 1.0);
let thin_stroke = set_live_part(&mut s, g, None, Some((100.0, 50.0)), vec![stroke(red(), 0.0)], 1.0);
let clear_stroke = set_live_part(&mut s, g, None, Some((50.0, 50.0)), vec![clear(stroke(red(), 2.0))], 1.0);
let clear_edge = set_live_part(&mut s, g, None, Some((75.0, 0.0)), vec![stroke(red(), 2.0)], 0.0);
let parts = [lower_fill, hidden_fill, clear_fill, lower_stroke, hidden_stroke, thin_stroke, clear_stroke, clear_edge];
assert_eq!(parts.iter().collect::<std::collections::HashSet<_>>().len(), parts.len());
let before = node(&s, g);
s.execute("livePaint.expand", &json!({})).unwrap();
let n = node(&s, g);
let kept: Vec<NodeId> = n.children().unwrap().iter().map(|c| c.id).collect();
let has = |id: NodeId| kept.contains(&id);
assert!(has(lower_fill) && has(lower_stroke), "a painted lower row draws: {kept:?}");
for id in [hidden_fill, clear_fill, hidden_stroke, thin_stroke, clear_stroke, clear_edge] {
assert!(!has(id), "{id:?} draws nothing: {kept:?}");
}
// The kept parts keep every row.
let rows = |n: &Node, id: NodeId| n.children().unwrap().iter().find(|c| c.id == id).unwrap().appearance.items.clone();
assert_eq!(rows(&n, lower_fill), rows(&before, lower_fill));
assert_eq!(rows(&n, lower_stroke), rows(&before, lower_stroke));
}
/// Merge carries a face's whole appearance when one of its rows draws, and carries nothing from a
/// face that draws nothing.
#[test]
fn live_paint_merge_carries_the_faces_that_draw() {
use vectorcraft_doc::FillLayer;
use vectorcraft_tools::builder::face_at;
let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p));
let green = || Paint::solid(Color::rgb(0.0, 1.0, 0.0));
let mut s = session();
let g = live(&mut s);
let rows = vec![fill(green()), fill(Paint::None)];
set_live_part(&mut s, g, Some((25.0, 50.0)), None, rows.clone(), 1.0);
let mut only_hidden = fill(green());
if let AppearanceItem::Fill(f) = &mut only_hidden {
f.visible = false;
}
set_live_part(&mut s, g, Some((125.0, 50.0)), None, vec![only_hidden], 1.0);
let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0);
s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap();
let n = node(&s, g);
assert_eq!(face_at(&n, Point::new(25.0, 50.0)).unwrap().appearance.items, rows, "the drawn lower fill is carried");
let b = face_at(&n, Point::new(125.0, 50.0)).unwrap();
assert_eq!(b.appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), "the face takes its source's fill again");
assert!(b.appearance.has_drawn_fill());
}
/// Merge rebuilds the faces and edges as other paths, so the appearance it carries to them leaves
/// the strokes' pen pressure behind and keeps the rest.
#[test]
fn live_paint_merge_leaves_pen_pressure_behind() {
use vectorcraft_doc::{FillLayer, PressureProfile, StrokeLayer};
use vectorcraft_tools::builder::{edge_near, face_at};
let pressed = || {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
st.pressure = PressureProfile::from_samples([(0.0, 0.2), (0.5, 1.0), (1.0, 0.2)]);
assert!(st.pressure.is_some());
st
};
let green = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0))));
let mut s = session();
let g = live(&mut s);
set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![green.clone(), AppearanceItem::Stroke(pressed())], 1.0);
set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![AppearanceItem::Stroke(pressed())], 1.0);
let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0);
s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap();
let n = node(&s, g);
let mut plain = pressed();
plain.pressure = None;
let face = &face_at(&n, Point::new(75.0, 50.0)).unwrap().appearance.items;
assert_eq!(face, &vec![green, AppearanceItem::Stroke(plain.clone())]);
assert_eq!(edge_near(&n, Point::new(0.0, 50.0), 0.5).unwrap().appearance.items, vec![AppearanceItem::Stroke(plain)]);
}
/// The gradients Merge carries to the rebuilt faces and edges keep their fit on the old outline,
/// lower rows included.
#[test]
fn live_paint_merge_keeps_the_fit_of_carried_gradients() {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
use vectorcraft_doc::{FillLayer, StrokeLayer};
use vectorcraft_tools::builder::{edge_near, face_at};
let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)];
let unplaced = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let gradient = || Paint::Gradient(Box::new(unplaced.clone()));
let mut s = session();
let g = live(&mut s);
let rows = vec![AppearanceItem::Fill(FillLayer::new(gradient())), AppearanceItem::Fill(FillLayer::new(Paint::None))];
let face = set_live_part(&mut s, g, Some((25.0, 50.0)), None, rows, 1.0);
let edge = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![AppearanceItem::Stroke(StrokeLayer::new(gradient(), 2.0))], 1.0);
let before = node(&s, g);
let old = |id: NodeId| before.children().unwrap().iter().find(|c| c.id == id).unwrap().clone();
let (old_face, old_edge) = (old(face), old(edge));
let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0);
s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap();
let n = node(&s, g);
let geom = |items: &[AppearanceItem]| match items[0].paint() {
Paint::Gradient(gp) => gp.geom,
p => panic!("a gradient, not {p:?}"),
};
let face_bounds = old_face.geometric_bounds().unwrap();
let new_face = face_at(&n, Point::new(25.0, 50.0)).unwrap();
assert_eq!(geom(&new_face.appearance.items), Some(unplaced.resolve(face_bounds)), "the lower-row fill keeps its fit");
let AppearanceItem::Stroke(st) = &old_edge.appearance.items[0] else { panic!("a stroke") };
let edge_box = st.paint_bounds(old_edge.geometric_bounds().unwrap());
let new_edge = edge_near(&n, Point::new(0.0, 50.0), 0.5).unwrap();
assert_eq!(geom(&new_edge.appearance.items), Some(unplaced.resolve(edge_box)), "the stroke keeps its fit");
}
/// Merge carries a face's or edge's visibility, opacity and blending mode to the rebuilt part with
/// its appearance: a half-transparent face drawn by a stroke alone stays half transparent, and a
/// hidden edge stays hidden.
#[test]
fn live_paint_merge_keeps_the_visibility_opacity_and_blending_of_carried_parts() {
use vectorcraft_color::BlendMode;
use vectorcraft_doc::StrokeLayer;
use vectorcraft_tools::builder::{edge_near, face_at};
let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 2.0));
let mut s = session();
let g = live(&mut s);
let face = set_live_part(&mut s, g, Some((25.0, 50.0)), None, vec![stroke()], 0.5);
let edge = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![stroke()], 1.0);
s.edit("t", |d, _| {
d.node_mut(face).unwrap().blend = BlendMode::Multiply;
d.node_mut(edge).unwrap().visible = false;
Ok(())
})
.unwrap();
let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0);
s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap();
let n = node(&s, g);
let f = face_at(&n, Point::new(25.0, 50.0)).unwrap();
assert_eq!((f.appearance.items.clone(), f.opacity, f.blend, f.visible), (vec![stroke()], 0.5, BlendMode::Multiply, true));
let e = edge_near(&n, Point::new(0.0, 50.0), 0.5).unwrap();
assert_eq!((e.appearance.items.clone(), e.visible), (vec![stroke()], false), "the hidden edge stays hidden");
}
fn live_group_only(s: &mut Session) -> NodeId {
let r = s.execute("livePaint.make", &json!({})).unwrap();
NodeId(r["id"].as_u64().unwrap())
+112
View File
@@ -490,6 +490,118 @@ fn a_clipping_mask_of_ones_own_stays_when_the_link_is_updated() {
assert_eq!(mask_width(&s), Some(4.0), "the mask's path is kept");
}
/// [`two_plates`] on a 20 × 20 pt page, its composite drawn through the left half of it (same
/// length, so the plates stay where the header says).
fn two_plates_square() -> Vec<u8> {
let text = String::from_utf8(two_plates()).unwrap();
let text = text.replace("0 0 20 10", "0 0 20 20").replace("20 10 scale", "20 20 scale");
text.replacen("10 10 lineto 0 10 lineto", "10 20 lineto 0 20 lineto", 1).into_bytes()
}
/// The bounds (width, height) of the clipping path of clip group `g`.
fn clip_size(doc: &Document, g: NodeId) -> (f64, f64) {
let NodeKind::Group { children, clip: true } = &doc.node(g).unwrap().kind else { panic!("a clip group: {:?}", doc.node(g).unwrap().kind) };
let b = children[0].geometric_bounds().expect("a clipping path");
(b.width(), b.height())
}
#[test]
fn placement_options_clip_a_dcs_image_inside_its_files_clipping_path() {
let dir = temp_dir("placement-clip");
let path = write(&dir, "pic.eps", &two_plates());
let square = write(&dir, "square.eps", &two_plates_square());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
let id = image_of(&s, &r["ids"][0]).id;
s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap();
let depth = |doc: &Document| std::iter::successors(Some(id), |n| doc.parent_of(*n)).count();
let mut first = None;
// The square file fills the 20 × 10 pt placement and is cut to it; the file's own clipping
// path, the left half of its page, still clips the image inside that, on every relink.
for _ in 0..2 {
s.execute("links.relink", &json!({ "ids": [id.0], "path": square })).unwrap();
let doc = &s.doc().unwrap().doc;
let file = doc.parent_of(id).expect("the file's clip group");
let (w, h) = clip_size(doc, file);
assert!((w - 10.0).abs() < 1e-6 && (h - 20.0).abs() < 1e-6, "the file's clipping path: {w} × {h}");
let placement = doc.parent_of(file).expect("the placement's clip group");
let (w, h) = clip_size(doc, placement);
assert!((w - 20.0).abs() < 1e-6 && (h - 10.0).abs() < 1e-6, "the placement's clip: {w} × {h}");
assert_eq!(*first.get_or_insert(depth(doc)), depth(doc), "no group is added on a later relink");
}
// An update of the file keeps both.
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(first, Some(depth(doc)));
let file = doc.parent_of(id).unwrap();
assert!((clip_size(doc, file).0 - 10.0).abs() < 1e-6, "the file's clipping path");
assert!((clip_size(doc, doc.parent_of(file).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip");
// The file loses its clipping path and gains it again: the same two groups, no more.
let unclipped = String::from_utf8(two_plates_square()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
for _ in 0..2 {
std::fs::write(&square, unclipped.as_bytes()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(first, Some(depth(doc)), "the file's group stays, without its clip");
let plain = doc.parent_of(id).unwrap();
assert!(!matches!(doc.node(plain).unwrap().kind, NodeKind::Group { clip: true, .. }), "the file's group is not clipped");
assert!((clip_size(doc, doc.parent_of(plain).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip stays");
std::fs::write(&square, two_plates_square()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(first, Some(depth(doc)), "the file's clip group again, at the same depth");
let file = doc.parent_of(id).unwrap();
assert!((clip_size(doc, file).0 - 10.0).abs() < 1e-6, "the file's clipping path again");
assert!((clip_size(doc, doc.parent_of(file).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip");
}
}
#[test]
fn placement_options_clip_a_dcs_image_in_a_symbol_inside_its_files_clipping_path() {
let dir = temp_dir("placement-clip-symbol");
let path = write(&dir, "pic.eps", &two_plates());
let square = write(&dir, "square.eps", &two_plates_square());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
s.execute("symbol.new", &json!({ "name": "Pic", "ids": [r["ids"][0]] })).unwrap();
let list = s.execute("links.list", &json!({})).unwrap();
let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().expect("the symbol's image");
let id = row["id"].as_u64().unwrap();
s.execute("links.placementOptions", &json!({"ids": [id], "preserve": "fill", "clip": true})).unwrap();
// The clip groups around the symbol's image, innermost first.
let clips = |s: &Session| {
fn find(n: &Node, id: u64, around: &mut Vec<(f64, f64)>) -> bool {
if n.id.0 == id {
return true;
}
let Some(children) = n.children() else { return false };
if !children.iter().any(|c| find(c, id, around)) {
return false;
}
if let NodeKind::Group { clip: true, .. } = n.kind
&& let Some(b) = children[0].geometric_bounds()
{
around.push((b.width(), b.height()));
}
true
}
let mut around = vec![];
let doc = &s.doc().unwrap().doc;
assert!(find(&doc.symbols.iter().find(|w| w.name == "Pic").expect("the symbol").art, id, &mut around));
around
};
for _ in 0..2 {
s.execute("links.relink", &json!({ "ids": [id], "path": square })).unwrap();
let around = clips(&s);
assert_eq!(around.len(), 2, "the file's clip group inside the placement's: {around:?}");
// In the symbol's own units: the file's path as wide as the placement is tall, half as
// wide as it is tall, and the placement twice as wide as it is tall.
let ((fw, fh), (pw, ph)) = (around[0], around[1]);
assert!((fh - 2.0 * fw).abs() < 1e-6 && (fw - ph).abs() < 1e-6, "the file's clipping path: {around:?}");
assert!((pw - 2.0 * ph).abs() < 1e-6, "the placement's clip: {around:?}");
}
}
#[test]
fn a_dcs_file_takes_the_place_parameters_an_eps_file_takes() {
let dir = temp_dir("params");
+24
View File
@@ -126,6 +126,30 @@ fn bad_sides_radii_and_keys_are_errors() {
assert_eq!(corners(&s, id).0, [0.0; 4]);
}
/// Saved steps play as they did before `sides` and `radius` were checked: a negative radius
/// removes the rounding, a whole side count outside 3 to 1000 is brought into that range, and a
/// fractional or negative side count is ignored.
#[test]
fn saved_steps_with_out_of_range_sides_or_radius_still_play() {
let (mut s, id) = session();
let poly = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 80, "sides": 6})).unwrap()["id"].as_u64().unwrap());
let replay = |s: &mut Session, p: Value| s.replay("object.setLiveShape", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
run(&mut s, json!({"id": id.0, "radius": 6}));
replay(&mut s, json!({"id": id.0, "radius": -4}));
assert_eq!(corners(&s, id).0, [0.0; 4]);
let sides = |s: &Session| match node_path(s, poly).1 {
Some(LiveShape::Polygon { sides, .. }) => sides,
other => panic!("{other:?}"),
};
for (n, want) in [(json!(2), 3), (json!(1001), 1000), (json!(2.5), 1000), (json!(-1), 1000), (json!(5.0), 5)] {
replay(&mut s, json!({"ids": [poly.0], "sides": n}));
assert_eq!(sides(&s), want, "{n}");
}
// A direct call still refuses them.
assert!(s.execute("object.setLiveShape", &json!({"id": id.0, "radius": -4})).is_err());
assert!(s.execute("object.setLiveShape", &json!({"ids": [poly.0], "sides": 2})).is_err());
}
#[test]
fn direct_selected_corners_round_alone_and_stay_selected() {
let (mut s, id) = session();
+31 -1
View File
@@ -84,7 +84,7 @@ fn the_builtin_library_is_original_art_generated_in_code() {
let mut s = Session::new();
let list = run(&mut s, "symbol.libraries", json!({}));
let libs = list["libraries"].as_array().unwrap();
assert_eq!(libs.len(), 5, "{libs:?}");
assert_eq!(libs.len(), 6, "{libs:?}");
assert_eq!((libs[0]["id"].as_str(), libs[0]["category"].as_str()), (Some("basic-shapes"), Some("builtIn")));
assert_eq!(
(libs[1]["id"].as_str(), libs[1]["name"].as_str(), libs[1]["category"].as_str()),
@@ -102,6 +102,10 @@ fn the_builtin_library_is_original_art_generated_in_code() {
(libs[4]["id"].as_str(), libs[4]["name"].as_str(), libs[4]["category"].as_str()),
(Some("skill-icons"), Some("Skill Icons"), Some("builtIn"))
);
assert_eq!(
(libs[5]["id"].as_str(), libs[5]["name"].as_str(), libs[5]["category"].as_str()),
(Some("weather-icons"), Some("Weather Icons"), Some("builtIn"))
);
let lib = run(&mut s, "symbol.library", json!({"library": "basic shapes"}));
let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Circle", "Square", "Rounded Square", "Triangle", "Diamond", "Hexagon", "Star", "Burst", "Arrow"]);
@@ -215,6 +219,32 @@ fn map_markers_symbols_add_without_nine_slice() {
));
}
#[test]
fn weather_icons_library_lists_and_places() {
let mut s = session();
let lib = run(&mut s, "symbol.library", json!({"library": "weather-icons"}));
let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Sun", "Moon", "Cloud", "Rain", "Thunder", "Snow"]);
let mut placed = Vec::new();
for (i, name) in names.iter().enumerate() {
let r = run(
&mut s,
"symbol.addFromLibrary",
json!({"library": "weather-icons", "name": name, "place": true, "x": 100.0 + 80.0 * i as f64, "y": 100.0}),
);
assert_eq!(r["added"], json!([name]));
placed.push(r["placed"].as_u64().unwrap());
}
let d = doc(&s);
for (i, name) in names.iter().enumerate() {
assert_eq!(d.symbol_nine_slice(name), None, "{name} should not carry 9-slice guides");
assert!(matches!(
d.node(NodeId(placed[i])).map(|n| &n.kind),
Some(NodeKind::SymbolInstance { symbol, .. }) if symbol == *name
));
}
}
#[test]
fn a_saved_library_reopens_and_its_symbols_join_another_document_in_one_step() {
let dir = folder("user");
+361
View File
@@ -7358,6 +7358,367 @@ fn an_image_on_hidden_lines_does_not_come_in_beside_its_look() {
assert_eq!(art[0].name.as_deref(), Some("Photo"));
}
/// A 64 × 64 RGB image, every pixel of it different, 128 pt square at (20, 10) in `doc`.
fn speckled_photo(doc: &mut Document) -> Node {
let px = image::RgbImage::from_fn(64, 64, |x, y| image::Rgb([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8]));
image_node(doc, ImageBlob::png(png_of(px.into())), (64, 64), Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]), None)
}
/// The sizes `%%BeginData:` gives the images' samples in the program written from `doc`.
fn sample_sizes(doc: &Document) -> Vec<usize> {
let program = export_ai_editing(doc, &opts()).unwrap().program;
String::from_utf8_lossy(&program).lines().filter_map(|l| l.strip_prefix("%%BeginData: ")?.trim().parse().ok()).collect()
}
/// The samples of a 64 × 64 RGB image, as `%%BeginData:` counts them (`XI` and CR LF too).
const SPECKLED_WHOLE: usize = 64 * 64 * 3 + 4;
/// The pieces of an image cut into a raster mesh warp (as Object › Expand leaves a Warp or an
/// envelope on an image) are written as the image whole once, then each as the part of it that its
/// triangle shows: the file holds a little more than the image's pixels rather than a copy for every
/// piece, and it reads back drawing as it did.
#[test]
fn the_pieces_of_a_warped_image_are_written_as_the_parts_they_show() {
// GUARDS: each of a warped image's 200 pieces was written with all of the image's samples.
let doc = photo_doc(|d| {
let photo = speckled_photo(d);
let NodeKind::Image(im) = &photo.kind else { panic!("not an image") };
let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin());
vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 10, false, false)
});
let sizes = sample_sizes(&doc);
assert_eq!(sizes.len(), 200, "{sizes:?}");
assert_eq!(sizes[0], SPECKLED_WHOLE, "the first piece holds the whole image");
// Each piece holds its cell (two triangles share one) and 2 pixels more around it.
let total: usize = sizes.iter().sum();
assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}");
let back = read_back(&doc);
let st = vectorcraft_testkit::raster::perceptual_diff(
&vectorcraft_testkit::raster::render_artboard(&doc),
&vectorcraft_testkit::raster::render_artboard(&back),
8.0,
);
assert!((st.bad_pixels as f64) < 0.005 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels);
}
/// The pieces of a warped image that Preserve Shape Using Transparency cuts with opacity masks that
/// clip are written as the parts their masks show, in a group without knockout, and the file reads
/// back drawing as it did.
#[test]
fn masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show() {
// GUARDS: each masked piece of a warped image was written with all of the image's samples.
// GUARDS: the pieces' knockout group read back with each piece hiding the pieces below it.
let doc = photo_doc(|d| {
let photo = speckled_photo(d);
let NodeKind::Image(im) = &photo.kind else { panic!("not an image") };
let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin());
vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, true)
});
assert_eq!(doc.layers[0].children().unwrap()[0].knockout, vectorcraft_doc::Knockout::On);
let sizes = sample_sizes(&doc);
assert_eq!(sizes.len(), 32, "{sizes:?}");
assert_eq!(sizes[0], SPECKLED_WHOLE, "the first piece holds the whole image");
let total: usize = sizes.iter().sum();
assert!(total < 6 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}");
let back = read_back(&doc);
let st = vectorcraft_testkit::raster::perceptual_diff(
&vectorcraft_testkit::raster::render_artboard(&doc),
&vectorcraft_testkit::raster::render_artboard(&back),
8.0,
);
assert!((st.bad_pixels as f64) < 0.005 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels);
}
/// The masked pieces of a warped image whose pixels are half transparent read back drawing as they
/// did, except where neighboring pieces overlap: there the image's alpha draws twice.
#[test]
fn masked_pieces_of_a_translucent_warped_image_read_back_but_for_their_overlaps() {
// GUARDS: the pieces' knockout group read back with each piece hiding the pieces below it
// (8,942 of 20,000 pixels differed).
let doc = photo_doc(|d| {
let px = image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8, 128]));
let photo = image_node(d, ImageBlob::png(png_of(px.into())), (64, 64), Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]), None);
let NodeKind::Image(im) = &photo.kind else { panic!("not an image") };
let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin());
vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, true)
});
let back = read_back(&doc);
let st = vectorcraft_testkit::raster::perceptual_diff(
&vectorcraft_testkit::raster::render_artboard(&doc),
&vectorcraft_testkit::raster::render_artboard(&back),
8.0,
);
assert!((st.bad_pixels as f64) < 0.05 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels);
}
/// An image with a live Warp keeps its pixels whole once, and its drawn look's pieces are each the
/// part of them they show: it reads back as the image with its Warp live.
#[test]
fn an_image_with_a_live_warp_writes_its_pixels_whole_once() {
// GUARDS: a live Warp's drawn look wrote all of the image's samples for each of its pieces.
let doc = photo_doc(|d| {
let mut photo = speckled_photo(d);
photo.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into();
photo
});
let sizes = sample_sizes(&doc);
assert!(sizes.len() > 100, "{sizes:?}");
assert_eq!(sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(), 1, "{sizes:?}");
let total: usize = sizes.iter().sum();
assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}");
let back = read_back(&doc);
let art = back.layers[0].children().unwrap();
assert_eq!(art.len(), 1, "{:#?}", lines(&back));
assert!(matches!(art[0].kind, NodeKind::Image(_)), "{:?}", art[0].kind);
assert_eq!(art[0].appearance.effects.len(), 1, "{:?}", art[0].appearance);
}
/// An image cut to a clipping path is written with all of its pixels when it is the first of them
/// written, so releasing the clipping path shows the whole image; a second copy cut to a small part
/// is written as that part, 2 pixels more on each side.
#[test]
fn a_clipped_image_written_first_keeps_all_of_its_pixels() {
let window = |d: &mut Document| {
let photo = speckled_photo(d);
let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0)));
group(d, vec![corner, photo], true)
};
let doc = photo_doc(|d| {
let (a, b) = (window(d), window(d));
group(d, vec![a, b], false)
});
let sizes = sample_sizes(&doc);
assert_eq!(sizes.len(), 2, "{sizes:?}");
assert_eq!(sizes[0], SPECKLED_WHOLE);
// The path shows 10 × 10 pixels.
assert_eq!(sizes[1], 14 * 14 * 3 + 4, "{sizes:?}");
let back = read_back(&doc);
let st = vectorcraft_testkit::raster::perceptual_diff(
&vectorcraft_testkit::raster::render_artboard(&doc),
&vectorcraft_testkit::raster::render_artboard(&back),
8.0,
);
assert!((st.bad_pixels as f64) < 0.002 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels);
}
/// The images in `n` and its members, each as its count of effects, its width and its height.
fn images_in(n: &Node, out: &mut Vec<(usize, u32, u32)>) {
if let NodeKind::Image(im) = &n.kind {
out.push((n.appearance.effects.len(), im.width, im.height));
}
for c in n.children().into_iter().flatten() {
images_in(c, out);
}
}
/// A copy of an image written whole already that its clipping path shows none of is written as one
/// pixel of it.
#[test]
fn a_copy_of_an_image_that_does_not_show_is_one_pixel() {
// GUARDS: a copy whose clipping path missed the image was written with all of its pixels.
let doc = photo_doc(|d| {
let photo = speckled_photo(d);
let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0)));
let first = group(d, vec![corner, photo], true);
let photo = speckled_photo(d);
let away = clipping(d, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 5.0)));
let second = group(d, vec![away, photo], true);
group(d, vec![first, second], false)
});
assert_eq!(sample_sizes(&doc), [SPECKLED_WHOLE, 3 + 4]);
}
/// A 200 × 100 document of: the speckled image when `first`; a square whose opacity mask's art is
/// the image then six squares; the image in a small clipping group.
fn mask_art_then_clipped_photo(first: bool) -> Document {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
if first {
let photo = speckled_photo(&mut doc);
doc.insert(Some(layer), usize::MAX, photo).unwrap();
}
let mut masked = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::gray(0.5));
// The image, then more objects than the clipping group after the mask holds.
let mut members = vec![speckled_photo(&mut doc)];
for i in 0..6 {
let x = f64::from(i) * 10.0;
members.push(square(&mut doc, Rect::new(x, 0.0, x + 5.0, 5.0), Color::rgb(1.0, 1.0, 1.0)));
}
let art = group(&mut doc, members, false);
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
doc.insert(Some(layer), usize::MAX, masked).unwrap();
let photo = speckled_photo(&mut doc);
let corner = clipping(&mut doc, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0)));
let clipped = group(&mut doc, vec![corner, photo], true);
doc.insert(Some(layer), usize::MAX, clipped).unwrap();
doc
}
/// For each object limit from none to all of `doc`'s objects: whether the mask is written, and the
/// sizes `%%BeginData:` gives the images' samples on `%_` lines (the mask's art) and on the others.
fn samples_within_each_limit(doc: &Document) -> Vec<(usize, bool, Vec<usize>, Vec<usize>)> {
let read = crate::import::objects_read(&export_ai_editing(doc, &opts()).unwrap().program).unwrap();
(0..=read)
.map(|max| {
let text = String::from_utf8_lossy(&export_within(doc, &opts(), max).unwrap().program).replace("\r\n", "\n");
let size = |l: &str| l.strip_prefix("%%BeginData: ")?.trim().parse::<usize>().ok();
let hidden = text.lines().filter_map(|l| size(l.strip_prefix("%_")?)).collect();
let shown = text.lines().filter_map(size).collect();
(max, text.contains("(AI9_Mask)"), hidden, shown)
})
.collect()
}
/// An image whose whole copy was in art left out for the object limit (here an opacity mask's art)
/// is written whole again where it is next written, even cut to a small clipping path: the file
/// always holds all of its pixels once.
#[test]
fn an_image_whose_whole_copy_is_left_out_is_written_whole_again() {
// GUARDS: once the mask's art was left out, the clipped copy was written as the part it shows.
let mut left_out_with_copy = 0;
for (max, mask, hidden, shown) in samples_within_each_limit(&mask_art_then_clipped_photo(false)) {
if hidden.is_empty() && shown.is_empty() {
continue;
}
assert!(hidden.contains(&SPECKLED_WHOLE) || shown.contains(&SPECKLED_WHOLE), "room for {max}: {hidden:?} {shown:?}");
left_out_with_copy += usize::from(!mask);
}
assert!(left_out_with_copy > 0, "no room left the mask out and kept the clipped copy");
}
/// An image written whole, then whole again in an opacity mask's art left out for the object limit,
/// is not written whole a third time: the first copy is kept, and the clipped copy after the mask is
/// the part it shows.
#[test]
fn an_image_written_whole_before_art_left_out_stays_written_whole() {
// GUARDS: the mask's copy took the first copy's place, so giving it back wrote the clipped copy whole.
let mut left_out = 0;
for (max, mask, _, shown) in samples_within_each_limit(&mask_art_then_clipped_photo(true)) {
assert!(shown.iter().filter(|s| **s == SPECKLED_WHOLE).count() <= 1, "room for {max}: {shown:?}");
left_out += usize::from(!mask && shown.len() == 2);
}
assert!(left_out > 0, "no room left the mask out and kept both copies");
}
/// An image with a live Warp in a small clipping group keeps all of its pixels: its drawn look is
/// written first, and neither the look nor the image itself is cut to the clipping path, so it
/// reads back with its Warp live and every pixel it had.
#[test]
fn a_clipped_image_with_a_live_warp_keeps_all_of_its_pixels() {
// GUARDS: the image under a live Warp was cut to the clipping path around it.
let doc = photo_doc(|d| {
let mut photo = speckled_photo(d);
photo.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into();
let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 60.0, 50.0)));
group(d, vec![corner, photo], true)
});
let sizes = sample_sizes(&doc);
assert_eq!(sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(), 1, "the look's first piece: {sizes:?}");
let total: usize = sizes.iter().sum();
assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}");
// The image itself, on `%_` lines.
let program = export_ai_editing(&doc, &opts()).unwrap().program;
let hidden: Vec<usize> =
String::from_utf8_lossy(&program).lines().filter_map(|l| l.strip_prefix("%_%%BeginData: ")?.trim().parse().ok()).collect();
assert_eq!(hidden, [SPECKLED_WHOLE]);
let back = read_back(&doc);
let mut images = vec![];
for n in back.layers[0].children().unwrap() {
images_in(n, &mut images);
}
assert_eq!(images, [(1, 64, 64)], "{:#?}", lines(&back));
}
/// An image cut by an opacity mask whose art has an effect that paints past its path keeps the
/// pixels the effect shows: a copy of an image written whole already is cut to all that its mask
/// paints.
#[test]
fn a_masked_image_keeps_the_pixels_its_mask_effects_show() {
// GUARDS: the copy was cut to the mask art's path, short of what its Offset Path paints.
let doc = photo_doc(|d| {
let photo = speckled_photo(d);
let corner = clipping(d, shapes::rectangle(Rect::new(20.0, 10.0, 24.0, 14.0)));
let first = group(d, vec![corner, photo], true);
let mut masked = speckled_photo(d);
let mut ap = Appearance::basic(solid(Color::WHITE), Paint::None, 0.0);
ap.effects = vec![fx("path.offsetPath", serde_json::json!({"offset": 30.0}))];
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(70.0, 40.0, 80.0, 50.0)), ap);
masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }));
group(d, vec![first, masked], false)
});
let sizes = sample_sizes(&doc);
assert_eq!(sizes.len(), 2, "{sizes:?}");
assert_eq!(sizes[0], SPECKLED_WHOLE);
// The offset art covers (40, 10) to (110, 80): 35 × 35 pixels of the image.
assert!(sizes[1] >= 35 * 35 * 3, "{sizes:?}");
}
/// The speckled image with an opacity mask that clips it to six small white squares in its corner.
fn photo_masked_to_its_corner(doc: &mut Document) -> Node {
let mut masked = speckled_photo(doc);
let squares = (0..6).map(|i| square(doc, Rect::new(20.0 + f64::from(i) * 5.0, 10.0, 23.0 + f64::from(i) * 5.0, 20.0), Color::WHITE)).collect();
let art = group(doc, squares, false);
masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }));
masked
}
/// A copy of an image written whole already whose opacity mask is left out for the object limit is
/// written whole: without the mask, it shows all of its pixels.
#[test]
fn a_masked_copy_whose_mask_is_left_out_keeps_all_of_its_pixels() {
// GUARDS: the copy was cut to its mask, and the mask then left out, so it read back cropped.
let doc = photo_doc(|d| {
let photo = speckled_photo(d);
let masked = photo_masked_to_its_corner(d);
group(d, vec![photo, masked], false)
});
let (mut left_out, mut kept) = (0, 0);
for (max, mask, _, shown) in samples_within_each_limit(&doc) {
if shown.len() < 2 {
continue;
}
if mask {
assert!(shown[1] < SPECKLED_WHOLE, "room for {max}: {shown:?}");
kept += 1;
} else {
assert_eq!(shown, [SPECKLED_WHOLE, SPECKLED_WHOLE], "room for {max}");
left_out += 1;
}
}
assert!(left_out > 0 && kept > 0, "{left_out} limits left the mask out, {kept} kept it");
}
/// A copy of an image written whole already whose opacity mask is nested too deep to be written is
/// written whole.
#[test]
fn a_masked_copy_nested_too_deep_for_its_mask_keeps_all_of_its_pixels() {
// GUARDS: the copy was cut to its mask, and the mask then left out for its depth.
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let photo = speckled_photo(&mut doc);
doc.insert(Some(layer), usize::MAX, photo).unwrap();
// Isolated instances are written as their art, each a level deeper: the masked copy is at the limit.
let art = photo_masked_to_its_corner(&mut doc);
doc.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(art) });
for i in 1..writer::MAX_NEST {
let mut inner = instance(&mut doc, &format!("S{}", i - 1), Affine::IDENTITY);
inner.isolate = true;
doc.symbols.push(Symbol { dynamic: false, name: format!("S{i}"), art: Arc::new(inner) });
}
let mut outer = instance(&mut doc, &format!("S{}", writer::MAX_NEST - 1), Affine::IDENTITY);
outer.isolate = true;
doc.insert(Some(layer), usize::MAX, outer).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::DEEP_MASKS), "{:?}", out.warnings);
// The symbols' definitions hold copies too: each copy written with its mask is the part the
// mask shows, and each copy whose mask is left out is whole.
let sizes = sample_sizes(&doc);
let whole = sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count();
let masks = String::from_utf8_lossy(&out.program).matches("(AI9_Mask)").count();
assert_eq!(sizes.len() - whole, masks, "{sizes:?}");
assert!(whole >= 2, "the first copy and a copy whose mask is left out: {sizes:?}");
}
/// [`warped_photo`] linked to `/Users/someone/Job/art/photo.png`, in folder `art` next to the
/// document's.
fn linked_warped_photo(doc: &mut Document, effects: Vec<vectorcraft_doc::Effect>) -> Node {
+83 -12
View File
@@ -281,6 +281,17 @@ pub(super) struct Writer<'a> {
/// How many more envelopes may be written live: as many as the reader checks in one file
/// ([`crate::import::ENVELOPE_CHECKS`]).
envelopes_left: usize,
/// The image read last and its pixels ([`images::Samples::of`], `None` when they can't be
/// read): the pieces of a warped image, which follow one another, read them once.
last_samples: Option<(vectorcraft_doc::ImageBlob, Option<images::Read>)>,
/// The images written whole so far, by the address of their data (which each entry keeps), each
/// with the index of its samples in [`Self::payloads`].
whole_images: HashMap<usize, (Arc<Vec<u8>>, usize)>,
/// The outlines of the clipping groups the art being written is in, in the document (empty in
/// a definition, an opacity mask's art and a brush's art, which are written apart from them).
shown: Vec<vectorcraft_geom::BezPath>,
/// The copy of an image that the last [`Self::image`] wrote as a window, when it did.
windowed: Option<images::Windowed>,
}
impl<'a> Writer<'a> {
@@ -348,6 +359,10 @@ impl<'a> Writer<'a> {
definition_objects: 0,
definition_symbol_nodes: 0,
max_objects: crate::import::MAX_EDITING_OBJECTS,
last_samples: None,
whole_images: HashMap::new(),
shown: vec![],
windowed: None,
}
}
@@ -414,15 +429,21 @@ impl<'a> Writer<'a> {
/// Art written since `c` as `lines` is not used: the objects and symbol art charged since are
/// given back, except those of the definitions written since, and the images `lines` marks
/// are emptied (the others keep their indices).
/// are emptied (the others keep their indices). An image whose whole copy is among them is no
/// longer written whole ([`Self::whole_images`]).
fn give_back(&mut self, c: Charges, lines: &str) {
self.objects = c.objects + (self.definition_objects - c.kept_objects);
self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes);
let mut emptied = HashSet::new();
for index in lines.split(PAYLOAD).skip(1).step_by(2) {
if let Some(p) = index.parse::<usize>().ok().and_then(|i| self.payloads.get_mut(i)) {
if let Some((i, p)) = index.parse::<usize>().ok().and_then(|i| Some((i, self.payloads.get_mut(i)?))) {
*p = Vec::new();
emptied.insert(i);
}
}
if !emptied.is_empty() {
self.whole_images.retain(|_, (_, i)| !emptied.contains(i));
}
}
/// Whether the art of a symbol, `art`, may be written: the objects of the symbol art written
@@ -643,8 +664,17 @@ impl<'a> Writer<'a> {
}
self.effects(n);
self.state(n.locked, !n.visible);
let xy = Xy::of(n);
let mut xy = Xy::of(n);
if masked_warp_pieces(n) {
// The reader gives the pieces no mark, and a knockout group's member then knocks out
// what it paints without its opacity mask: each piece would hide the pieces below it.
// Without knockout, a partly transparent image draws its alpha twice only where the
// pieces overlap.
xy.knockout = Knockout::Off;
}
let start = self.body.len();
// An image's copy written as the part that its opacity mask, among other regions, shows.
let mut mask_window = None;
match &n.kind {
NodeKind::Layer { .. } => {}
NodeKind::Group { children, clip: true } => self.clip_group(children, xy),
@@ -671,7 +701,25 @@ impl<'a> Writer<'a> {
}
NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy),
NodeKind::Text(_) => self.text(n, xy),
NodeKind::Image(im) => self.image(&self.doc.shown_image(n, im), xy),
NodeKind::Image(im) => {
// An opacity mask that clips shows the image within what its art paints only (its
// effects and its symbol instances' art included, as the renderer measures it).
let masked = n
.extras()
.mask
.as_ref()
.filter(|m| m.clip && !m.invert && !m.disabled && self.nest < MAX_NEST)
.and_then(|m| vectorcraft_render::painted_bounds(self.doc, &m.art));
if let Some(b) = masked {
self.shown.push(b.to_path(0.1));
}
self.windowed = None;
self.image(&self.doc.shown_image(n, im), xy);
if masked.is_some() {
self.shown.pop();
mask_window = self.windowed.take();
}
}
NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy),
NodeKind::SymbolInstance { symbol, xf } => {
if !self.open_group("u") {
@@ -737,10 +785,14 @@ impl<'a> Writer<'a> {
// A group's dictionary goes between its end and its state block.
let block = (self.block_end == self.body.len() && self.block_start >= start).then(|| self.body.split_off(self.block_start));
self.block_end = usize::MAX;
self.dictionary(n);
let mask_written = self.dictionary(n);
if let Some(b) = block {
self.body.push_str(&b);
}
// Without its opacity mask, the image shows whole.
if let Some(w) = mask_window.filter(|_| !mask_written) {
self.write_whole(w);
}
}
// An envelope's plugin `XP` follows its distorted art, after the art's state block.
if deform {
@@ -773,25 +825,28 @@ impl<'a> Writer<'a> {
/// is bound to variables ([`Self::bound_variables`]; without a name, it gets an XML id and an
/// empty `AIArtName`), has text wrap options ([`Self::text_wrap`]), has an opacity mask
/// (`AI9_Mask`, see [`Self::mask`]), or is a symbol's art (its symbol's entries first, see
/// [`Self::define_symbol`]).
fn dictionary(&mut self, n: &Node) {
/// [`Self::define_symbol`]). False when `n` has an opacity mask that is left out: nested too
/// deep, or its art doesn't fit.
fn dictionary(&mut self, n: &Node) -> bool {
let name = n.name.as_deref().filter(|s| !s.is_empty());
let wrap = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look);
let placed = std::mem::take(&mut self.placed);
let typed = std::mem::take(&mut self.typed);
let bound = self.bound_variables(n, placed, typed);
let mut mask = n.extras().mask.as_deref();
let mut mask_written = true;
// Masks nested too deep are left out (an empty mask would hide the object).
if mask.is_some() && self.nest >= MAX_NEST {
self.warn(DEEP_MASKS);
mask = None;
mask_written = false;
}
let entries = match &self.root_entries {
Some((id, _)) if *id == n.id => self.root_entries.take().map(|(_, e)| e),
_ => None,
};
if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() && wrap.is_none() {
return;
return mask_written;
}
let start = self.body.len();
let has_id = name.is_some() || !bound.is_empty();
@@ -817,13 +872,15 @@ impl<'a> Writer<'a> {
// A mask whose art doesn't fit is left out, and with nothing else in it the dictionary too.
if let Some(m) = mask
&& !self.mask(m)
&& !has_id
&& !has_entries
&& wrap.is_none()
{
return self.body.truncate(start);
if !has_id && !has_entries && wrap.is_none() {
self.body.truncate(start);
return false;
}
mask_written = false;
}
self.body.push_str("%_;\n%_\n");
mask_written
}
/// Text wrap options, as entries of their object's dictionary: the offset in art space's points
@@ -866,6 +923,7 @@ impl<'a> Writer<'a> {
fn mask(&mut self, m: &OpacityMask) -> bool {
let outer = std::mem::take(&mut self.body);
let (state, gs, clip) = (self.state, self.gs, self.clip.take());
let shown_by = std::mem::take(&mut self.shown);
let (charges, bound) = (self.charges(), self.bound.len());
(self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE));
// The canvas draws the art's root even when it is hidden, and the reader puts a hidden object
@@ -905,6 +963,7 @@ impl<'a> Writer<'a> {
self.hidden -= 1;
self.nest -= 1;
let lines = std::mem::replace(&mut self.body, outer);
self.shown = shown_by;
if self.left_out_since(charges) {
// The art isn't written, nor are the variable bindings of its objects.
self.give_back(charges, &lines);
@@ -990,9 +1049,11 @@ impl<'a> Writer<'a> {
}
}
self.clip_paint_part(first, &path, rule, &fills);
self.shown.push(path.to_bezpath());
for c in rest {
self.node(c);
}
self.shown.pop();
self.clip_paint_part(first, &path, rule, &strokes);
self.state(first.locked, !first.visible);
let start = self.body.len();
@@ -1667,6 +1728,16 @@ fn clip_path(n: &Node) -> Option<(PathData, FillRule)> {
path.subpaths.iter().any(|sp| !sp.anchors.is_empty()).then_some((path, rule))
}
/// Is `n` a knockout group of the pieces of a mesh-warped image cut by opacity masks
/// ([`vectorcraft_doc::live::mesh_warp_image`] with Preserve Shape Using Transparency)? Each piece
/// knocks out its triangle ([`Node::knockout_outline`]); read from the data, a piece knocks out all
/// that its image paints instead.
fn masked_warp_pieces(n: &Node) -> bool {
n.knockout == Knockout::On
&& matches!(&n.kind, NodeKind::Group { children, clip: false }
if !children.is_empty() && children.iter().all(|c| c.extras().warp_piece && c.extras().mask.is_some()))
}
/// `text`'s lines on `%_` lines, as a part of the data that readers of the printed form skip (an
/// opacity mask's art, a pattern's tile) has them. An image's samples stay as they are after an
/// `XI` without `%_`, and `%_%%EndData` ends them, as in the files users sent. Samples hidden
+2 -2
View File
@@ -422,7 +422,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas),
self.state.take(),
self.gs.replace(Xy::OPAQUE),
self.clip.take(),
(self.clip.take(), std::mem::take(&mut self.shown)),
(self.block_start, self.block_end),
// The art is the brush's own: not on `%_` lines, in a drawn look or in a symbol that
// first uses the brush.
@@ -459,7 +459,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas),
self.state,
self.gs,
self.clip,
(self.clip, self.shown),
(self.block_start, self.block_end),
(self.hidden, self.in_look, self.in_symbol),
) = saved;
+93 -7
View File
@@ -2,13 +2,14 @@
//! (see [`crate::ai_editing`] for the form).
use std::fmt::Write;
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{FillLayer, ImageBlob, ImageObject, LinkInfo};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Shape};
use super::{Writer, Xy};
use crate::ps;
use crate::{ai_ops, ps};
pub(in crate::ai_editing) const IMAGES: &str = "images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray in the editing data for other apps";
pub(in crate::ai_editing) const IMAGE_DEPTH: &str = "images of 16 bits a channel are written at 8 bits a channel in the editing data for other apps";
@@ -26,8 +27,23 @@ pub(in crate::ai_editing) const PAYLOAD: char = '\u{1}';
/// What starts the line of an image's samples: `XI` and its payload, then `%%EndData`.
pub(super) const SAMPLES_LINE: &str = "XI\u{1}";
/// An image's pixels as the data holds them, and whether it had 16 bits a channel ([`Samples::of`]).
pub(super) type Read = Arc<(Samples, bool)>;
/// A copy of an image written as the part of it that its regions show ([`Writer::image`]).
pub(super) struct Windowed {
/// Where its raster is in the body.
at: std::ops::Range<usize>,
/// The index of its samples in [`Writer::payloads`].
payload: usize,
/// The image's pixels.
samples: Read,
/// What places the image's whole pixel grid in the document.
xf: Affine,
}
/// An image's pixels as the data holds them.
struct Samples {
pub(super) struct Samples {
/// `/DeviceGray`, `/DeviceRGB` or `/DeviceCMYK`.
space: &'static str,
/// The image type of `XI` (§8.1): 1 gray, 3 RGB, 4 CMYK.
@@ -79,6 +95,19 @@ impl Samples {
Some((Samples { space, kind, width, height, color, alpha }, deep))
}
/// The pixels of `window` (`[x0, y0, x1, y1]`, within the image) alone.
fn window(&self, window: [u32; 4]) -> Samples {
let [x0, y0, x1, y1] = window;
Samples {
space: self.space,
kind: self.kind,
width: x1 - x0,
height: y1 - y0,
color: ai_ops::window_samples(&self.color, self.width, usize::from(self.kind), window),
alpha: self.alpha.as_ref().map(|a| ai_ops::window_samples(a, self.width, 1, window)),
}
}
/// `XI`'s fourth flag as the files users sent have it: 0 gray, 1 RGB, 2 CMYK, 16 more with an
/// alpha channel.
fn reserved(&self) -> u8 {
@@ -96,10 +125,13 @@ impl Writer<'_> {
/// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on
/// `%_` lines (other than a styled object's copy on the layers, [`Self::styled_object`]) and in
/// a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is
/// cut to a clipping path, the image is in a clipping group of that path.
/// cut to a clipping path, the image is in a clipping group of that path; a raster of pixels
/// written whole already is then the part of them that the path shows ([`ai_ops::image_window`]).
pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) {
let doc = self.doc;
let Some((samples, deep)) = doc.image_blob(im).and_then(Samples::of) else {
let blob = doc.image_blob(im);
let read = blob.and_then(|b| self.samples(b));
let Some((samples, deep)) = read.as_deref() else {
let bounds = PathData::from_bezpath(&(im.xf * Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1)));
self.warn(IMAGES);
return self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(Color::gray(0.5))), xy);
@@ -113,7 +145,7 @@ impl Writer<'_> {
if !self.take_objects(1 + clipping) {
return;
}
if deep {
if *deep {
self.warn(IMAGE_DEPTH);
}
let cut = self.clip.take();
@@ -129,13 +161,41 @@ impl Writer<'_> {
Some(link) if (self.hidden == 0 || self.styled_copy) && !self.in_symbol => {
// Cut to a region, the placed file is in a clipping group.
self.placed = cut.is_none();
self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf);
self.placed_file(link, samples, (im.width.max(1), im.height.max(1)), im.xf);
}
other => {
if other.is_some() {
self.warn(LINKS_EMBEDDED);
}
self.raster(&samples, im.xf * fit);
// An image written whole already: the part its region shows, placed where it is in
// the image (the pieces of a warped image each show a small part of the same one).
// An object's editable original (on `%_` lines) and an image in an opacity mask's art
// are written whole.
let data = blob.map(|b| &b.bytes);
let written = self.hidden == 0 && data.is_some_and(|d| self.whole_images.contains_key(&(Arc::as_ptr(d) as usize)));
let window = written
.then(|| {
let clips: Vec<_> = self.shown.iter().cloned().chain(cut.iter().map(PathData::to_bezpath)).collect();
ai_ops::image_window(&clips, im.xf * fit, (samples.width, samples.height))
})
.flatten();
match (window, &read) {
(Some(window), Some(read)) => {
let at = self.body.len();
self.raster(&samples.window(window), im.xf * fit * Affine::translate((f64::from(window[0]), f64::from(window[1]))));
self.windowed =
Some(Windowed { at: at..self.body.len(), payload: self.payloads.len() - 1, samples: read.clone(), xf: im.xf * fit });
}
_ => {
if let Some(d) = data {
// `raster` adds the samples as the next payload. A copy written whole on
// `%_` lines after an earlier whole copy keeps the earlier one's entry.
let next = self.payloads.len();
self.whole_images.entry(Arc::as_ptr(d) as usize).or_insert_with(|| (d.clone(), next));
}
self.raster(samples, im.xf * fit);
}
}
}
}
if let Some(region) = &cut {
@@ -232,6 +292,32 @@ impl Writer<'_> {
);
}
/// The pixels of `blob` ([`Samples::of`]), read once for images that follow one another with
/// the same data.
fn samples(&mut self, blob: &ImageBlob) -> Option<Read> {
if let Some((last, read)) = &self.last_samples
&& Arc::ptr_eq(&last.bytes, &blob.bytes)
&& last.mime == blob.mime
{
return read.clone();
}
let read = Samples::of(blob).map(Arc::new);
self.last_samples = Some((blob.clone(), read.clone()));
read
}
/// The copy `w`, written as a window, written whole in its place, with fresh samples; its
/// window's samples are emptied.
pub(super) fn write_whole(&mut self, w: Windowed) {
let tail = self.body.split_off(w.at.end);
self.body.truncate(w.at.start);
if let Some(p) = self.payloads.get_mut(w.payload) {
*p = Vec::new();
}
self.raster(&w.samples.0, w.xf);
self.body.push_str(&tail);
}
/// Keep `bytes` to go in the program where the mark this returns is.
fn payload(&mut self, bytes: Vec<u8>) -> String {
self.payloads.push(bytes);
+2 -2
View File
@@ -481,7 +481,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas),
self.state.take(),
self.gs.replace(Xy::OPAQUE),
self.clip.take(),
(self.clip.take(), std::mem::take(&mut self.shown)),
self.block_end,
);
// `Bg` places a gradient in a pattern's tile as on the canvas, mirrored top to bottom: its
@@ -505,7 +505,7 @@ impl Writer<'_> {
self.hidden -= 1;
self.nest -= 1;
let art;
(art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, self.clip, self.block_end) = saved;
(art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, (self.clip, self.shown), self.block_end) = saved;
let art = std::mem::replace(&mut self.body, art);
self.defining.pop();
if self.left_out_since(before) {
@@ -750,6 +750,9 @@ impl Writer<'_> {
let xy = Xy::of(orig);
let was = self.in_look;
self.in_look = true;
// The reader draws the look apart from the clipping groups around the object to compare it
// with the object's effects, and keeps the object itself: neither is cut to those groups.
let shown_by = std::mem::take(&mut self.shown);
self.line("u");
let mut look = n.clone();
(look.name, look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape) =
@@ -800,6 +803,7 @@ impl Writer<'_> {
}
self.dictionary(orig);
self.hidden -= 1;
self.shown = shown_by;
let part = std::mem::replace(&mut self.body, saved);
hide(&part, &mut self.body);
self.block_end = usize::MAX;
+2 -2
View File
@@ -245,7 +245,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas, self.instance_space),
self.state.replace((false, false)),
self.gs.replace(Xy::OPAQUE),
self.clip.take(),
(self.clip.take(), std::mem::take(&mut self.shown)),
(self.block_start, self.block_end),
(std::mem::replace(&mut self.in_symbol, true), std::mem::take(&mut self.hidden)),
self.root_entries.take(),
@@ -302,7 +302,7 @@ impl Writer<'_> {
(self.to_art, self.grad_space, self.canvas, self.instance_space),
self.state,
self.gs,
self.clip,
(self.clip, self.shown),
(self.block_start, self.block_end),
(self.in_symbol, self.hidden),
self.root_entries,
+50 -1
View File
@@ -7,7 +7,7 @@ use std::fmt::Write;
use vectorcraft_color::{Color, Gradient};
use vectorcraft_doc::{LAYER_COLORS, LayerColor, LineCap, LineJoin, StrokeLayer};
use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath};
use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, Shape, SubPath};
use crate::ps;
@@ -131,6 +131,55 @@ pub(crate) fn average(g: &Gradient) -> Color {
Color::rgb(unit(sum[0] / n), unit(sum[1] / n), unit(sum[2] / n))
}
/// The pixels an image's part keeps past the edge of the region that shows it, on each side: as
/// many as smoothing reads from beside a pixel, and one more.
const WINDOW_MARGIN: f64 = 2.0;
/// The part of an image of `w` × `h` pixels, which `to_doc` places in the document, that regions
/// `clips` (each in the document, the image cut to all of them) show, with [`WINDOW_MARGIN`] pixels
/// more on each side: `[x0, y0, x1, y1]`, in pixels from the top left. One pixel of the image, the
/// nearest to where the regions are, when they show none of it. `None` when that part has half the
/// image's pixels or more, or when `to_doc` can't be undone; the whole image is written then.
pub(crate) fn image_window(clips: &[kurbo::BezPath], to_doc: Affine, (w, h): (u32, u32)) -> Option<[u32; 4]> {
if clips.is_empty() || w == 0 || h == 0 || !to_doc.is_finite() || to_doc.determinant().abs() < 1e-12 {
return None;
}
let to_pixels = to_doc.inverse();
let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, f64::from(w), f64::from(h));
for clip in clips {
let b = (to_pixels * clip.clone()).bounding_box();
if !b.is_finite() {
return None;
}
x0 = f64::max(x0, b.x0.floor() - WINDOW_MARGIN);
y0 = f64::max(y0, b.y0.floor() - WINDOW_MARGIN);
x1 = f64::min(x1, b.x1.ceil() + WINDOW_MARGIN);
y1 = f64::min(y1, b.y1.ceil() + WINDOW_MARGIN);
}
if x0 >= x1 || y0 >= y1 {
let (x, y) = ((x0 as u32).min(w - 1), (y0 as u32).min(h - 1));
return Some([x, y, x + 1, y + 1]);
}
if 2.0 * (x1 - x0) * (y1 - y0) >= f64::from(w) * f64::from(h) {
return None;
}
Some([x0 as u32, y0 as u32, x1 as u32, y1 as u32])
}
/// The rows `y0..y1` and columns `x0..x1` (`window`) of `samples`, `w` pixels a row of `channels`
/// bytes each, rows from the top.
pub(crate) fn window_samples(samples: &[u8], w: u32, channels: usize, [x0, y0, x1, y1]: [u32; 4]) -> Vec<u8> {
let row = w as usize * channels;
let (from, to) = (x0 as usize * channels, x1 as usize * channels);
let mut out = Vec::with_capacity((y1 - y0) as usize * (to - from));
for y in y0 as usize..y1 as usize {
if let Some(part) = samples.get(y * row + from..y * row + to) {
out.extend_from_slice(part);
}
}
out
}
/// The stroke's width, cap, join, miter limit and dashes: `w J j M d`.
pub(crate) fn line_style(s: &mut String, st: &StrokeLayer) {
let cap = match st.cap {
+2 -1
View File
@@ -18,7 +18,8 @@
//! 1 pt draws it at its own size), the brush by name (`/GObjRef : (name) (plug-in)
//! /PluginObject`), and the brush's settings for this path as entries: `Diameter`, `Roundness`,
//! `Angle`, their `…Variation` either side and `…DependsOn` codes, and `AngleRelativeTo` (0 the
//! page).
//! page). Samples of the pen may follow a path's own lines on `%_` lines (`(hex) Xt n XD`). They
//! aren't read, so such a path keeps its drawn look when a setting varies with the pen.
//! - A path with an Art brush is a stroke in its art style too: the filter
//! `(Adobe ArtOnPath Brush Tool)`, its stroke's paint and weight (the weight scales the art across
//! the path), the brush by name, and its settings for this path as entries: `Scaling`,
+5
View File
@@ -599,6 +599,10 @@ struct Reader<'a> {
compound_groups: HashSet<NodeId>,
/// Groups of a text object and its look (see [`Self::style_marker`]), which aren't a look.
text_looks: HashSet<NodeId>,
/// Objects followed by samples of the pen (`(…) Xt n XD`), which aren't read: such an object
/// whose style has a Calligraphic brush that varies with the pen keeps its drawn look (see
/// [`Self::style_marker`]).
pen_samples: HashSet<NodeId>,
/// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line.
mesh: Option<(super::mesh::MeshReader, bool)>,
/// The art styles (`%AI9_BeginArtStyles`), by name.
@@ -791,6 +795,7 @@ impl<'a> Reader<'a> {
ruler_guides: HashMap::new(),
compound_groups: HashSet::new(),
text_looks: HashSet::new(),
pen_samples: HashSet::new(),
group_ended: false,
mesh_ended: false,
layer_state: None,
+32 -5
View File
@@ -89,6 +89,18 @@ impl<'a> Reader<'a> {
}
}
/// `(…) Xt n XD`: samples of the pen along the object before it. They aren't read, so the object
/// keeps its drawn look when its style has a Calligraphic brush with a setting that varies with
/// the pen ([`Self::style_marker`]).
fn pen_samples_op(&mut self) {
if !self.after_object || self.pen_samples.len() >= MAX_NODES {
return;
}
if let Some(n) = self.frames.last().and_then(|f| f.children.last()) {
self.pen_samples.insert(n.id);
}
}
/// `/identifier (string) XT` (version 7 specification §7): a tag on the object before it. The
/// `AdobeURL` tag sets the object's URL, and every other tag is added to the object's data in
/// order, up to [`MAX_TAGS`]. A tag with no object before it, with an empty identifier, or with
@@ -418,6 +430,7 @@ impl<'a> Reader<'a> {
self.end_envelope();
}
}
"XD" => self.pen_samples_op(),
_ if IGNORED.contains(&w) => {}
// Before the art, the setup runs procedures of the printing format.
_ if self.frames.is_empty() => {}
@@ -559,9 +572,9 @@ impl<'a> Reader<'a> {
return;
}
self.image_tail = false;
// An object's tags (`XT`, and a graph's `Go`) follow it one after another, and an image's
// follow the `XH` that ends its data.
if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH" | "Go") {
// An object's tags (`XT`, and a graph's `Go`) and samples of the pen (`Xt`, `XD`) follow it
// one after another, and an image's tags follow the `XH` that ends its data.
if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH" | "Go" | "Xt" | "XD") {
return;
}
self.after_object = false;
@@ -688,6 +701,8 @@ impl<'a> Reader<'a> {
/// drawn look before it (all on `%_` lines inside another such object). Other markers end what
/// follows an object.
fn style_marker(&mut self, vals: &[V], hidden: bool) {
use crate::import::ai::brush::PathBrush;
use crate::import::ai::style::{ArtStyle, StyleItem};
let code = Self::nums(vals).last().copied().unwrap_or(0.0);
let style = vals.iter().rev().find_map(|v| self.names.text(v)).unwrap_or_default();
let after = std::mem::take(&mut self.after_object);
@@ -754,8 +769,20 @@ impl<'a> Reader<'a> {
} else {
look
};
// A style read here: the object, painted as it says.
let appearance = self.styles.get(&style).and_then(|st| {
// A style read here: the object, painted as it says. An object with samples of the pen
// keeps its drawn look when its style has a Calligraphic brush with a setting that varies
// with the pen: the samples aren't read, and the brush draws such a stroke at the pen's
// middle values.
let sampled = self.pen_samples.contains(&object.id);
let follows_pen = |st: &ArtStyle| {
st.items.iter().any(|i| match i {
StyleItem::Stroke { brush: Some((_, PathBrush::Calligraphic(c))), .. } => {
c.modes().iter().zip(c.variation).any(|(m, v)| m.follows_pen() && v != 0.0)
}
_ => false,
})
};
let appearance = self.styles.get(&style).filter(|st| !(sampled && follows_pen(st))).and_then(|st| {
styled(
object,
look,
+1 -2
View File
@@ -386,9 +386,8 @@ pub(super) const IGNORED: &[&str] = &[
// Path resolution (`Ar`, §12): how finely curves print.
"Ar",
"Xs",
// The samples of the pen that `XD` notes on the object before them (`Reader::pen_samples`).
"Xt",
// Five indexed curves of samples along a brushed path (`(…) Xt n XD`), on its hidden source path.
"XD",
"Xi",
"XI",
"LB2",
+28 -3
View File
@@ -1445,13 +1445,38 @@ fn offset_stroke(f: &Filter) -> Result<Option<StyleItem>, ()> {
stroke_item(f, if inside { StrokeAlign::Inside } else { StrokeAlign::Outside })
}
/// The entries a Calligraphic brush's filter may hold, with the settings [`calligraphic_stroke`]
/// reads. Any other entry keeps the drawn look.
const CALLIGRAPHIC_ENTRIES: [&str; 14] = [
"StrokeStyle",
"DisplayString",
"Brush",
"Diameter",
"DiameterVariation",
"DiameterDependsOn",
"Roundness",
"RoundnessVariation",
"RoundnessDependsOn",
"Angle",
"AngleVariation",
"AngleDependsOn",
"AngleRelativeTo",
"Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files
];
/// A Calligraphic brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a
/// colour, names no Calligraphic brush, or its settings aren't all there as numbers. An angle
/// relative to the path isn't read.
/// color, names no Calligraphic brush, holds an entry or a width that isn't read, or its settings
/// aren't all there as numbers. An angle relative to the path isn't read.
fn calligraphic_stroke(f: &Filter) -> Option<StyleItem> {
let real = |key: &str| entry(f, key, Kind::Real).ok().flatten();
let code = |key: &str| entry(f, key, Kind::Int).ok().flatten();
if !f.paints.0 || f.gradient || f.pattern.is_some() || f.repeated {
if !f.paints.0
|| f.gradient
|| f.pattern.is_some()
|| f.repeated
|| !f.arrays.is_empty()
|| !f.kinds.keys().all(|k| CALLIGRAPHIC_ENTRIES.contains(&k.as_str()))
{
return None;
}
if entry(f, "AngleRelativeTo", Kind::Int).ok()?.unwrap_or(0.0) != 0.0 {
+67 -1
View File
@@ -10304,20 +10304,86 @@ fn a_path_with_a_calligraphic_brush_comes_in_brushed() {
let pieces = vectorcraft_brush::stroke_pieces(&r.doc, &lib[0], &a[0].path_data().unwrap().to_bezpath(), st);
let height = pieces.iter().filter_map(|n| n.geometric_bounds()).map(|b| b.height()).fold(0.0, f64::max);
assert!(height > 40.0 && height < 46.0, "the line's rise and about half the nib's 3 pt: {height}");
// A filter with the version entry an effect's filter may carry still comes in brushed.
let versioned = style.replace("(Brush) ,\n", "(Brush) ,\n15 /Int (Adobe Effect Expand Before Version) ,\n");
let a = art(&read_ok(&format!("{def}{versioned}{}", layer(&brushed_path()))).doc);
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{a:#?}");
}
/// Samples of the pen along a path (`(…) Xt n XD`), as they follow its own lines.
const PEN_SAMPLES: &str = "%_(00FE64FE) Xt 0 XD\n%_(00006400) Xt 1 XD\n%_(00986498) Xt 2 XD\n%_(009E649E) Xt 3 XD\n%_(007F647F) Xt 4 XD\n";
/// A path with a Calligraphic brush that varies with the pen, followed by samples of the pen, comes
/// in as its drawn look alone, with the note that says so: the samples aren't read, and the brush
/// would draw it at the pen's middle values. Its own path doesn't come in beside the look. A brush
/// that doesn't follow the pen draws such a path the same without the samples, so it comes in
/// brushed.
#[test]
fn a_brushed_path_with_samples_of_the_pen_keeps_its_drawn_look() {
// GUARDS: the path came in beside its look, unpainted, and the note was missing.
let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D);
let style = brushed_style("Six D", 0.5, SIX_D_SETTINGS, [2, 5, 6], 0);
let path = brushed_path().replace("%_n\n", &format!("%_n\n{PEN_SAMPLES}"));
let r = read_ok(&format!("{def}{style}{}", layer(&path)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "the drawn look: {a:#?}");
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
// The samples belong to the path before them: a path after them is read as it was.
let after = format!("{path}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0));
let a = art(&read_ok(&format!("{def}{style}{}", layer(&after))).doc);
assert_eq!(a.len(), 2, "{a:#?}");
assert!(matches!(a[1].kind, NodeKind::Path { .. }) && a[1].appearance.fill().is_some(), "{a:#?}");
// Samples with no object before them in the layer mark nothing: the path after them is read.
let first = format!("{PEN_SAMPLES}{}", brushed_path());
let a = art(&read_ok(&format!("{def}{style}{}", layer(&first))).doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path, brushed: {a:#?}");
// A brush whose modes are Fixed and Random, one whose pen modes vary by 0, and an Art brush
// don't follow the pen: the path comes in brushed.
let fixed_def = plugin_object(CALLIGRAPHIC, "Six D", &SIX_D.replace(" 2 5 6 0", " 0 1 0 0"));
let fixed = brushed_style("Six D", 0.5, SIX_D_SETTINGS, [0, 1, 0], 0);
let even_def = plugin_object(CALLIGRAPHIC, "Six D", "1 6 6 40 40 30 30 2 5 6 0");
let even = brushed_style("Six D", 0.5, [(6.0, 0.0), (40.0, 0.0), (30.0, 0.0)], [2, 5, 6], 0);
for (why, def, style) in [("Fixed and Random", fixed_def, fixed), ("no variation", even_def, even)] {
let r = read_ok(&format!("{def}{style}{}", layer(&path)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{why}: {a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{why}: the path, brushed: {a:#?}");
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings);
}
let ribbon = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON));
let a = art(&read_ok(&format!("{ribbon}{}{}", art_style("Ribbon", 2.0, &STRETCHED, ""), layer(&path))).doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon"), "{a:#?}");
// A style with no brush doesn't draw the samples: the path takes it as it would without them.
let square = styled_square("Blue").replace("f\n1 (Blue) XW", &format!("f\n{PEN_SAMPLES}1 (Blue) XW"));
let r = read_ok(&format!("{}{}", styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]), layer(&square)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path with its style: {a:#?}");
assert_eq!(a[0].extras().graphic_style, Some(1), "linked to its style");
}
/// A brushed path keeps its drawn look when its stroke can't be read as the brush: the object's
/// own settings differ from the brush's (Options of Selected Object), its angle follows the path,
/// or the file defines no brush of its name.
/// its filter holds an entry or a variable width that isn't read, or the file defines no brush of
/// its name.
#[test]
fn a_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D);
let mut own = SIX_D_SETTINGS;
own[0] = (9.0, 2.0);
let plain = brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0);
let with = |more: &str| plain.replace("(Brush) ,\n", &format!("(Brush) ,\n{more}"));
let widths = "/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
/Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n";
let cases = [
("its own settings", def.clone(), brushed_style("Six D", 1.0, own, [2, 5, 6], 0)),
("its own modes", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 4], 0)),
("an angle on the path", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 1)),
("an entry that isn't read", def.clone(), with("1 /Real (Unread) ,\n")),
("a variable width", def.clone(), with(widths)),
("no such brush", def, brushed_style("Other", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0)),
];
for (why, def, style) in cases {
+176 -2
View File
@@ -26,8 +26,8 @@ use std::collections::{BTreeMap, HashMap};
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{
CharStyle, HyphenationOptions, JustificationOptions, Justify, LineCap, LineJoin, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign,
PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun,
Burasagari, CharStyle, HyphenationOptions, JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LineCap, LineJoin, Node, NodeId, NodeKind,
ParaDirection, ParaStyle, PathAlign, PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun,
};
use vectorcraft_geom::{Affine, PathData, Point, Rect};
@@ -405,6 +405,42 @@ struct Para {
tabs: Vec<TabStop>,
/// The paragraph has more than [`MAX_TABS`] stops: those past them were left out.
tabs_truncated: bool,
/// Key 27: the Kinsoku set, `/nil` for None, else the index of one of the document's sets
/// ([`Texts::kinsoku_sets`]). `None`: the default.
kinsoku: Option<Kinsoku>,
/// Key 27 names a set of the document that isn't Hard or Soft: the paragraph takes the set it
/// would have without it.
kinsoku_custom: bool,
/// Key 25: Kinsoku Shori Type, 0 Push In First, 1 Push Out First, 2 Push Out Only. `None`: the
/// default.
kinsoku_type: Option<KinsokuShoriType>,
/// Key 24: Burasagari, 0 None, 1 Regular, 2 Force. `None`: the default.
burasagari: Option<Burasagari>,
/// Key 23: Bunri-Kinshi.
bunri_kinshi: bool,
}
/// The name of the document's predefined Hard Kinsoku set (key 0 of its entry in the document's
/// Kinsoku sets).
const KINSOKU_HARD: &str = "PhotoshopKinsokuHard";
/// The name of the document's predefined Soft Kinsoku set.
const KINSOKU_SOFT: &str = "PhotoshopKinsokuSoft";
/// The warning when a paragraph's Kinsoku set is one VectorCraft doesn't have.
pub(crate) const KINSOKU_SETS_LEFT_OUT: &str = "custom Kinsoku sets were left out: their paragraphs take the set their paragraph style has";
/// The Kinsoku sets of text document `doc` (the list at key 0 of key 4 of its resources), in
/// order, each by its name (key 0 of the set): Hard or Soft, `None` for any other set. Those past
/// [`MAX_SHEETS`] are left out.
fn kinsoku_sets(doc: &Val) -> Vec<Option<Kinsoku>> {
let list = doc.get("0").and_then(|d| d.get("4")).and_then(|s| s.get("0")).map(Val::list).unwrap_or_default();
list.iter()
.take(MAX_SHEETS)
.map(|s| match s.get("0").and_then(|r| r.get("0")).and_then(Val::str) {
Some(KINSOKU_HARD) => Some(Kinsoku::Hard),
Some(KINSOKU_SOFT) => Some(Kinsoku::Soft),
_ => None,
})
.collect()
}
/// Most tab stops read for one paragraph.
@@ -542,6 +578,9 @@ pub(super) struct Texts {
/// The patterns that paint characters, by the name of the art style that gives each
/// ([`Self::with_patterns`]).
patterns: HashMap<String, Paint>,
/// The document's Kinsoku sets (key 4 of its resources), in order, each by its name (key 0):
/// Hard or Soft, `None` for any other set.
kinsoku_sets: Vec<Option<Kinsoku>>,
}
impl Texts {
@@ -611,6 +650,7 @@ impl Texts {
para_sheets: sheets("6"),
frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(),
patterns: HashMap::new(),
kinsoku_sets: kinsoku_sets(&doc),
})
}
@@ -749,7 +789,37 @@ impl Texts {
chain.extend(self.para_sheets.first().and_then(|s| s.get("5")));
let pick = |k: &str| chain.iter().find_map(|s| s.get(k)).or_else(|| self.para_defaults.get(k));
let num = |k: &str| pick(k).and_then(Val::num).filter(|v| v.abs() < 1e5).unwrap_or(0.0);
// The East Asian settings: the first value along the chain that can be read, so one that
// can't be read takes the paragraph style's.
let levels = || chain.iter().copied().chain(std::iter::once(&self.para_defaults));
let choice = |k: &str| {
levels().find_map(|s| match s.get(k) {
Some(Val::Num(v)) if *v == 0.0 || *v == 1.0 || *v == 2.0 => Some(*v as u8),
_ => None,
})
};
let set = |v: &Val| match v {
Val::Name(n) if n == "nil" => Some(Some(Kinsoku::None)),
Val::Num(i) if *i >= 0.0 && i.fract() == 0.0 => self.kinsoku_sets.get(*i as usize).copied(),
_ => None,
};
Para {
kinsoku: levels().find_map(|s| s.get("27").and_then(set).flatten()),
kinsoku_custom: levels().find_map(|s| s.get("27").and_then(set)).is_some_and(|k| k.is_none()),
kinsoku_type: choice("25").map(|v| match v {
0 => KinsokuShoriType::PushInFirst,
1 => KinsokuShoriType::PushOutFirst,
_ => KinsokuShoriType::PushOutOnly,
}),
burasagari: choice("24").map(|v| match v {
0 => Burasagari::None,
1 => Burasagari::Standard,
_ => Burasagari::Forced,
}),
bunri_kinshi: levels().find_map(|s| match s.get("23") {
Some(Val::Bool(b)) => Some(*b),
_ => None,
}) == Some(true),
justify: pick("0").and_then(Val::num).map_or(0, |v| v.clamp(0.0, 6.0) as u8),
rtl: pick("33").and_then(Val::num) == Some(1.0),
first_indent: num("1"),
@@ -1264,6 +1334,11 @@ impl Story {
self.tabs_left_out.get()
}
/// Does a paragraph have a Kinsoku set VectorCraft doesn't have ([`Para::kinsoku_custom`])?
pub(super) fn kinsoku_sets_left_out(&self) -> bool {
self.paras.iter().any(|(_, p)| p.kinsoku_custom)
}
/// Area type (`node`, the story's first frame as [`Self::node`] makes it with `template` and
/// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its
/// page draws it: its first baseline is fixed where the file has it. The font's ascent below the
@@ -1425,6 +1500,10 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle {
auto_leading: p.auto_leading.map_or(JustificationOptions::default().auto_leading, |a| (a * 1e6).round() / 1e4),
..JustificationOptions::default()
},
kinsoku: p.kinsoku.unwrap_or_default(),
kinsoku_type: p.kinsoku_type.unwrap_or_default(),
burasagari: p.burasagari.unwrap_or_default(),
bunri_kinshi: p.bunri_kinshi,
..ParaStyle::default()
}
}
@@ -1496,6 +1575,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let justify = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justify;
// Right to left from the document's defaults, or the paragraph's own.
@@ -1520,6 +1600,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let auto = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justification.auto_leading;
assert_eq!(auto(b""), JustificationOptions::default().auto_leading);
@@ -1543,6 +1624,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let options = |own: &[u8], scale: f64| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale).hyphenation;
let want = HyphenationOptions { words_longer_than: 6, after_first: 2, before_last: 2, hyphen_limit: 0, zone: 36.0, capitalized: true };
@@ -1561,12 +1643,93 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let own = |own: &[u8]| para_style(&bare.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).hyphenation;
assert_eq!(own(b""), d);
assert_eq!(own(b"/10 0 /11 99 /12 2.5 /13 -1 /14 -3 /15 1"), d);
}
/// The document's Kinsoku sets are known by their names, in order: the predefined Hard and
/// Soft sets, in either string form, and any other set as one VectorCraft doesn't have.
#[test]
fn the_documents_kinsoku_sets_are_known_by_name() {
let utf16: Vec<u8> = b"(\xfe\xff".iter().copied().chain(KINSOKU_SOFT.bytes().flat_map(|c| [0, c])).chain(*b")").collect();
let doc = read(
&[
b"/0 << /4 << /0 [ << /0 << /0 (PhotoshopKinsokuHard) /5 << /4 1 >> >> >> << /0 << /0 ".as_slice(),
&utf16,
b" >> >> << /0 << /0 (My Set) >> >> << /0 << >> >> ] >> >>",
]
.concat(),
)
.unwrap();
assert_eq!(kinsoku_sets(&doc), [Some(Kinsoku::Hard), Some(Kinsoku::Soft), None, None]);
assert_eq!(kinsoku_sets(&read(b"/0 << >>").unwrap()), []);
}
/// Keys 23 to 27 are the paragraph's East Asian settings: Bunri-Kinshi (23), Burasagari (24:
/// 0 None, 1 Regular, 2 Force), Kinsoku Shori Type (25: 0 Push In First, 1 Push Out First, 2
/// Push Out Only) and the Kinsoku set (27: `/nil` for None, else the index of one of the
/// document's sets). A value that can't be read, and a set VectorCraft doesn't have, take the
/// paragraph style's, or else the document's.
#[test]
fn a_paragraph_keeps_its_east_asian_settings() {
let texts = Texts {
stories: vec![],
fonts: vec![],
defaults: Val::Dict(BTreeMap::new()),
para_defaults: read(b"/27 0 /25 2 /24 0 /23 false").unwrap(),
char_sheets: vec![],
// [Normal Paragraph Style] has the Soft set; the second style has Force burasagari.
para_sheets: vec![read(b"/5 << /27 1 >>").unwrap(), read(b"/5 << /24 2 /23 true >>").unwrap()],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![Some(Kinsoku::Hard), Some(Kinsoku::Soft), None],
};
let para = |run: &[u8]| texts.para(Some(&read(run).unwrap()));
let settings = |run: &[u8]| {
let p = para_style(&para(run), 1.0);
(p.kinsoku, p.kinsoku_type, p.burasagari, p.bunri_kinshi)
};
let normal = (Kinsoku::Soft, KinsokuShoriType::PushOutOnly, Burasagari::None, false);
assert_eq!(settings(b"/5 << >>"), normal);
assert_eq!(settings(b"/5 << /27 /nil /25 0 /24 1 /23 true >>"), (Kinsoku::None, KinsokuShoriType::PushInFirst, Burasagari::Standard, true));
assert_eq!(settings(b"/5 << /27 0 /25 1 /24 2 >>"), (Kinsoku::Hard, KinsokuShoriType::PushOutFirst, Burasagari::Forced, false));
// The paragraph style it is based on (key 6), then [Normal Paragraph Style].
assert_eq!(settings(b"/5 << >> /6 1"), (Kinsoku::Soft, KinsokuShoriType::PushOutOnly, Burasagari::Forced, true));
assert_eq!(settings(b"/5 << /23 false >> /6 1"), (Kinsoku::Soft, KinsokuShoriType::PushOutOnly, Burasagari::Forced, false));
// A set VectorCraft doesn't have takes the paragraph style's, and the paragraph says so.
assert_eq!(settings(b"/5 << /27 2 >>"), normal);
assert!(para(b"/5 << /27 2 >>").kinsoku_custom);
assert!(!para(b"/5 << /27 0 >>").kinsoku_custom && !para(b"/5 << >>").kinsoku_custom);
// The story warns of it (the importer's warning).
assert!(story("あ\r", &[0.0], &para(b"/5 << /27 2 >>")).kinsoku_sets_left_out());
assert!(!story("あ\r", &[0.0], &para(b"/5 << /27 1 >>")).kinsoku_sets_left_out());
for unreadable in [
b"/5 << /27 3 >>".as_slice(),
b"/5 << /27 -1 >>",
b"/5 << /27 0.5 >>",
b"/5 << /27 (PhotoshopKinsokuHard) >>",
b"/5 << /27 /Hard >>",
b"/5 << /25 3 >>",
b"/5 << /25 1.5 >>",
b"/5 << /25 -1 >>",
b"/5 << /24 /nil >>",
b"/5 << /24 true >>",
b"/5 << /23 1 >>",
b"/5 << /23 /nil >>",
] {
assert_eq!(settings(unreadable), normal, "{}", String::from_utf8_lossy(unreadable));
assert!(!para(unreadable).kinsoku_custom, "{}", String::from_utf8_lossy(unreadable));
}
// Without any of the keys, VectorCraft's defaults.
let bare = Texts { para_defaults: Val::Dict(BTreeMap::new()), para_sheets: vec![], kinsoku_sets: vec![], ..texts };
let p = para_style(&bare.para(Some(&read(b"/5 << >>").unwrap())), 1.0);
let d = ParaStyle::default();
assert_eq!((p.kinsoku, p.kinsoku_type, p.burasagari, p.bunri_kinshi), (d.kinsoku, d.kinsoku_type, d.burasagari, d.bunri_kinshi));
}
/// Key 30 holds the paragraph's tab stops: their positions (key 0), sorted, and alignments (key
/// 1: 0 left, 1 center, 2 right, 3 decimal), scaled as sizes are. A stop that can't be read is
/// left out. A user's price list keeps a right stop at 269.29 and a left one at 272.13.
@@ -1581,6 +1744,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let tabs = |own: &[u8], scale: f64| {
para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale)
@@ -1648,6 +1812,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
}
.para(None);
let mut s = story(" 80 mm ", &[0.0], &para);
@@ -1696,6 +1861,7 @@ mod tests {
para_sheets: vec![],
frames: cases.iter().map(|c| frame(c)).collect(),
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let vertical: Vec<bool> = (0..cases.len() + 1).map(|i| texts.frame_vertical(i)).collect();
assert_eq!(vertical, [true, false, false, false, false, false, false, false, false, false, false]);
@@ -1714,6 +1880,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let tcy = |run: &[u8]| texts.style(Some(&read(run).unwrap())).tate_chu_yoko;
assert!(tcy(b"/6 << /35 3 >>"), "the run's own");
@@ -1738,6 +1905,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let roman = |defaults: &[u8], run: &[u8]| texts(defaults).style(Some(&read(run).unwrap())).standard_vertical_roman;
assert!(roman(b"/35 1", b"/6 << >>"), "the document's defaults");
@@ -1769,6 +1937,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let stroke = |run: &[u8]| {
let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0, Affine::IDENTITY);
@@ -1811,6 +1980,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
}
.with_patterns(patterns)
}
@@ -1894,6 +2064,7 @@ mod tests {
para_sheets: vec![],
frames: vec![read(b"/0 << /0 [ 0 0 ] /2 << /0 0 >> >>").unwrap()],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let text = |kerns: &[u8]| -> TextObject {
let node = texts(kerns).story(0).unwrap().node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY).unwrap();
@@ -1961,6 +2132,7 @@ mod tests {
para_sheets: vec![],
frames: vec![],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
let direction = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).direction;
assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults");
@@ -2009,6 +2181,7 @@ mod tests {
para_sheets: vec![],
frames: vec![read(doc.as_bytes()).unwrap()],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
texts.frame(0)
}
@@ -2667,6 +2840,7 @@ mod tests {
para_sheets: vec![],
frames: vec![read(doc.as_bytes()).unwrap()],
patterns: HashMap::new(),
kinsoku_sets: vec![],
};
texts.frame(0).unwrap()
}
+5 -1
View File
@@ -59,7 +59,7 @@ pub(super) fn make_slots(
to_doc: Affine,
warn: &mut Vec<String>,
) -> Vec<Option<Made>> {
let (mut taller, mut deeper, mut tabs_left_out) = (0, 0, false);
let (mut taller, mut deeper, mut tabs_left_out, mut kinsoku_left_out) = (0, 0, false, false);
let made = infos
.iter()
.enumerate()
@@ -68,6 +68,7 @@ pub(super) fn make_slots(
let story = stories.get(k)?.as_ref()?;
let mut node = story.node(NodeId(0), info.frame, t, to_doc)?;
tabs_left_out |= story.tabs_left_out();
kinsoku_left_out |= story.kinsoku_sets_left_out();
let mut region = story.region(t, to_doc);
// The first line where the file has it, in the text document's frame (that of a
// copied object is its original's, as its lines are).
@@ -102,6 +103,9 @@ pub(super) fn make_slots(
if tabs_left_out {
warn.push(crate::import::ate::TABS_LEFT_OUT.into());
}
if kinsoku_left_out {
warn.push(crate::import::ate::KINSOKU_SETS_LEFT_OUT.into());
}
made
}
+41
View File
@@ -658,6 +658,47 @@ fn images_are_written_in_the_form_the_specification_shows() {
assert!(rgb.iter().zip([247.0, 148.0, 29.0]).all(|(c, want)| (c * 255.0 - want).abs() < 0.1), "{rgb:?}");
}
/// The pieces of an image cut into a raster mesh warp (as Object › Expand leaves a Warp on an
/// image) are written as the image whole once, then each as the part of it that its triangle shows,
/// and the file draws as the document does ([`write`] runs it).
#[test]
fn the_pieces_of_a_warped_image_are_written_as_the_parts_they_show() {
// GUARDS: each of a warped image's 200 pieces was written with all of the image's samples.
let mut doc = Document::new(200.0, 160.0);
let mut png = vec![];
image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let blob = ImageBlob::new("image/png", png);
let key = blob.content_key();
doc.images.insert(key.clone(), blob);
let im = ImageObject {
key,
width: 64,
height: 64,
xf: Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]),
link: None,
placement: Default::default(),
pixels: None,
};
let photo = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im.clone())));
let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin());
let warped = vectorcraft_doc::live::mesh_warp_image(&photo, &im, &bend, 10, false, false);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, warped).unwrap();
let text = String::from_utf8(write(&doc).bytes).unwrap();
let images = rasters(&text);
assert_eq!(images.len(), 200);
let digits = |lines: &Vec<&str>| {
lines.iter().filter_map(|l| l.strip_prefix('%')).filter(|l| l.bytes().all(|b| b.is_ascii_hexdigit())).map(str::len).sum::<usize>()
};
let whole = 64 * 64 * 3 * 2;
assert_eq!(digits(&images[0]), whole, "the first piece holds the whole image");
// Each piece holds its cell (two triangles share one) and 2 pixels more around it.
let total: usize = images.iter().map(digits).sum();
assert!(total < 10 * whole, "{total} digits of samples for an image of {whole}");
}
/// An image whose data is empty is left out with the note for empty or too large images, and an
/// image whose data can't be decoded is left out with the decode note.
#[test]
+52 -9
View File
@@ -45,6 +45,13 @@ pub(super) struct Writer<'a> {
roman: bool,
/// Inside a Kumi block `1 TY` began (Tate-chu-yoko in horizontal type).
kumi: bool,
/// The outlines of the clipping groups the art being written is in, in the document.
clips: Vec<BezPath>,
/// The image decoded last (`None` when it can't be): the pieces of a warped image, which follow
/// one another, decode it once.
last_image: Option<(ImageBlob, Option<Arc<image::DynamicImage>>)>,
/// The images written whole so far, by the address of their data (which each entry keeps).
whole_images: std::collections::HashMap<usize, Arc<Vec<u8>>>,
}
impl<'a> Writer<'a> {
@@ -68,6 +75,9 @@ impl<'a> Writer<'a> {
fonts: vec![],
roman: false,
kumi: false,
clips: vec![],
last_image: None,
whole_images: std::collections::HashMap::new(),
}
}
@@ -363,12 +373,14 @@ impl<'a> Writer<'a> {
if several {
self.line("*U");
}
self.clips.push(outline);
if let Some(fill) = &paint.fill {
self.node(fill, locked);
}
for c in rest {
self.node(c, locked);
}
self.clips.pop();
self.lock(locked);
self.line("Q");
if let Some(stroke) = &paint.stroke {
@@ -1037,7 +1049,8 @@ impl<'a> Writer<'a> {
/// whose pixels are all black or white is written as an image mask (ImageMask 1, §8.1): its
/// color as the fill, then 1-bit samples, a 0 bit for each black pixel, which takes the color,
/// and a 1 bit for each white pixel, which is clear. Another colorized image is written with
/// its color in its pixels.
/// its color in its pixels. In a clipping group, an image whose pixels were written whole
/// already is written as the part of them the group shows ([`ai_ops::image_window`]).
fn image(&mut self, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine, colorize: Option<&Paint>) {
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let m = self.to_art * xf * flip;
@@ -1048,7 +1061,7 @@ impl<'a> Writer<'a> {
self.warn(LARGE_IMAGE);
return;
}
let Some(img) = blob.and_then(|b| image::load_from_memory(&b.bytes).ok()) else {
let Some((blob, img)) = blob.and_then(|b| Some((b, self.decoded(b)?))) else {
self.warn(UNDECODED);
return;
};
@@ -1059,14 +1072,31 @@ impl<'a> Writer<'a> {
}
// The object places the image's own pixel grid (its width × height) wherever the file's is.
let fit = Affine::scale_non_uniform(f64::from(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h));
let m = self.to_art * xf * fit * flip;
let coverage = match colorize {
Some(paint) => {
let Some((_, _, coverage)) = ImageBlob::coverage(blob).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else {
self.warn(UNDECODED);
return;
};
Some((paint, coverage))
}
None => None,
};
// An image written whole already, cut to a clipping group: the part the group shows, placed
// where it is in the image (the pieces of a warped image each show a small part of the same one).
let key = Arc::as_ptr(&blob.bytes) as usize;
let window = if self.whole_images.contains_key(&key) { ai_ops::image_window(&self.clips, xf * fit, (w, h)) } else { None };
if window.is_none() {
self.whole_images.insert(key, blob.bytes.clone());
}
let window = window.unwrap_or([0, 0, w, h]);
let [x0, y0, x1, y1] = window;
let m = self.to_art * xf * fit * Affine::translate((f64::from(x0), f64::from(y0))) * flip;
let (w, h) = (x1 - x0, y1 - y0);
// Over white.
let over = |c: u8, a: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8;
if let Some(paint) = colorize {
let Some((_, _, coverage)) = blob.and_then(ImageBlob::coverage).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else {
self.warn(UNDECODED);
return;
};
if let Some((paint, coverage)) = coverage {
let coverage = ai_ops::window_samples(&coverage, img.width(), 1, window);
if coverage.iter().all(|&v| v == 0 || v == 255) {
let stride = (w as usize).div_ceil(8);
let mut bits = vec![0xFF; stride.saturating_mul(h as usize)];
@@ -1086,7 +1116,7 @@ impl<'a> Writer<'a> {
self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples);
return;
}
let rgba = img.to_rgba8();
let rgba = img.crop_imm(x0, y0, w, h).to_rgba8();
let mut samples = Vec::with_capacity(rgba.len() / 4 * 3);
let mut see_through = false;
for px in rgba.pixels() {
@@ -1102,6 +1132,19 @@ impl<'a> Writer<'a> {
self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples);
}
/// The pixels of `blob`, decoded once for images that follow one another with the same data.
fn decoded(&mut self, blob: &ImageBlob) -> Option<Arc<image::DynamicImage>> {
if let Some((last, img)) = &self.last_image
&& Arc::ptr_eq(&last.bytes, &blob.bytes)
&& last.mime == blob.mime
{
return img.clone();
}
let img = image::load_from_memory(&blob.bytes).ok().map(Arc::new);
self.last_image = Some((blob.clone(), img.clone()));
img
}
/// An image's lines (§8.4): `%AI5_File:`, `%AI5_BeginRaster`, matrix `m` as `[ a b c d tx ty ]`
/// and the numbers `operands` with `XI`, `samples` in hex on comment lines of [`HEX_LINE`]
/// digits, `%AI5_EndRaster`, then `F`.
+14 -7
View File
@@ -931,18 +931,21 @@ impl<'a> Scene<'a> {
/// process inks the stops use, in CMYK order, and the spot inks, in the order the stops name
/// them. Paper white adds no ink, a gray stop is black ink, and a spot stop's process plates
/// print with its ink. An off-center midpoint is a stop
/// with the inks halfway between its two stops. `None` for a gradient without a spot ink, and,
/// with a warning, for one whose inks can't be kept or that is mixed by the Perceptual method
/// or around the hue wheel; it is then written in process colors.
/// with the inks halfway between its two stops. Stop opacity is left out, so the stops that
/// color a stretch ([`vectorcraft_color::Gradient::reached_stops`]) name the inks, a spot ink
/// only above a tint of 0 or with process plates; the others are written with no ink. `None` for a gradient without a
/// spot ink, and, with a warning, for one whose inks can't be kept or that is mixed by the
/// Perceptual method or around the hue wheel; it is then written in process colors.
fn ink_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<(GradientSpace, RawStops)> {
let doc = self.doc;
let spot_of = |s: &vectorcraft_color::GradientStop| -> Option<String> {
s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot)).map(str::to_string)
};
let reached = g.reached_stops();
// The spot inks, collected up to one more than a DeviceN space can hold.
let mut names: Vec<String> = vec![];
for s in &g.stops {
if let Some(n) = spot_of(s)
for (s, &r) in g.stops.iter().zip(&reached) {
if let Some(n) = spot_of(s).filter(|_| r && (s.tint > 0.0 || s.plates.is_some()))
&& !names.contains(&n)
{
names.push(n);
@@ -971,9 +974,10 @@ impl<'a> Scene<'a> {
};
// Each stop's ink coverage: the four process inks, then one tint per spot ink.
let mut inks: Vec<Vec<f32>> = Vec::with_capacity(g.stops.len());
for s in &g.stops {
for (s, &r) in g.stops.iter().zip(&reached) {
let mut v = vec![0.0; 4 + names.len()];
match spot_of(s) {
_ if !r => {}
Some(n) => {
if let Some(i) = names.iter().position(|m| *m == n) {
v[4 + i] = unit(s.tint);
@@ -1116,7 +1120,10 @@ impl<'a> Scene<'a> {
// keep no swatch links: the warning [`Self::ink_stops`] gives the others.
if g.gradient.method.is_hue() && g.gradient.is_spread() && g.gradient.spread == vectorcraft_color::GradientSpread::Reflect {
let doc = self.doc;
if g.gradient.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) {
let spot = |s: &vectorcraft_color::GradientStop| {
(s.tint > 0.0 || s.plates.is_some()) && s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))
};
if g.gradient.stops.iter().zip(g.gradient.reached_stops()).any(|(s, r)| r && spot(s)) {
self.warn(SPOT_GRADIENTS_HUE);
}
}
+60
View File
@@ -532,6 +532,66 @@ fn a_gradient_mixing_a_spot_ink_with_rgb_is_written_without_the_ink() {
assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings);
}
#[test]
fn gradients_name_only_the_spot_inks_of_stops_that_color_a_stretch() {
let black = |offset: f32| GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 1.0));
let paper = |offset: f32| GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 0.0));
let no_spot_space = |ps: &str| !ps.contains("(Gold Ink) cvn") && !ps.contains("/DeviceN") && !ps.contains("/Separation");
for level in [Level::Two, Level::Three] {
// Gold Ink out of order: the stops at 0 and 0.8 cover the stretches next to it, so it
// colors nothing and has no plate.
let d = spot_gradient_doc(vec![black(0.0), black(0.8), spot_stop(0.2, "Gold Ink", GOLD, 1.0), black(0.4), black(1.0)]);
let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap();
let ps = text(&out);
assert_eq!(dsc(&ps, "DocumentCustomColors"), None, "{level:?}: {ps}");
assert!(no_spot_space(&ps), "{level:?}: {ps}");
assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{level:?}: {:?}", out.warnings);
checked_page(&ps);
// Without it, Leaf Ink's tints over paper are a Separation shading.
let d = spot_gradient_doc(vec![
spot_stop(0.0, "Leaf Ink", LEAF, 1.0),
spot_stop(0.8, "Leaf Ink", LEAF, 0.5),
spot_stop(0.2, "Gold Ink", GOLD, 1.0),
paper(0.4),
paper(1.0),
]);
let ps = text(&export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap());
assert_eq!(dsc(&ps, "DocumentCustomColors").as_deref(), Some("(Leaf Ink)"), "{level:?}: {ps}");
assert!(ps.contains("/VCsp0 [/Separation (Leaf Ink) cvn /DeviceCMYK") && !ps.contains("(Gold Ink) cvn"), "{level:?}: {ps}");
checked_page(&ps);
// An RGB stop out of order colors nothing either, so it keeps no ink out.
let d = spot_gradient_doc(vec![
spot_stop(0.0, "Gold Ink", GOLD, 1.0),
spot_stop(0.8, "Gold Ink", GOLD, 0.5),
GradientStop::new(0.2, Color::rgb(0.0, 1.0, 0.0)),
paper(0.4),
paper(1.0),
]);
let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap();
let ps = text(&out);
assert!(ps.contains("/VCsp0 [/Separation (Gold Ink) cvn /DeviceCMYK"), "{level:?}: {ps}");
assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{level:?}: {:?}", out.warnings);
// Gold Ink at a tint of 0 prints no ink.
let d = spot_gradient_doc(vec![spot_stop(0.0, "Gold Ink", GOLD, 0.0), black(1.0)]);
let ps = text(&export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap());
assert_eq!(dsc(&ps, "DocumentCustomColors"), None, "{level:?}: {ps}");
assert!(no_spot_space(&ps), "{level:?}: {ps}");
}
// Gold Ink at a tint of 0 over a 30% cyan plate (a DeviceN gradient read from a PDF) keeps
// its process plate in the same space.
let plated = GradientStop { plates: Some([0.3, 0.0, 0.0, 0.0]), ..spot_stop(1.0, "Gold Ink", GOLD, 0.0) };
let d = spot_gradient_doc(vec![GradientStop::new(0.0, Color::cmyk(0.3, 0.0, 0.0, 0.0)), plated]);
let ps = text(&export(&d, &opts(&d), None, None).unwrap());
assert!(ps.contains("/VCdn0 [/DeviceN [/Cyan (Gold Ink) cvn ] /DeviceCMYK") && checked_page(&ps).contains("/C0 [0.3 0] /C1 [0.3 0]"), "{ps}");
// EPS leaves stop opacity out, so a clear Gold Ink stop between clear stops is written opaque
// and keeps its plate.
let clear = |s: GradientStop| GradientStop { opacity: 0.0, ..s };
let d = spot_gradient_doc(vec![black(0.0), clear(black(0.2)), clear(spot_stop(0.4, "Gold Ink", GOLD, 1.0)), clear(black(0.6)), black(1.0)]);
let ps = text(&export(&d, &opts(&d), None, None).unwrap());
assert_eq!(dsc(&ps, "DocumentCustomColors").as_deref(), Some("(Gold Ink)"), "{ps}");
assert!(ps.contains("/VCdn0 [/DeviceN [/Black (Gold Ink) cvn ] /DeviceCMYK"), "{ps}");
}
#[test]
fn spot_inks_named_all_or_none_are_written_under_names_of_their_own() {
let rect = |d: &mut Document, fill: &str, stroke: &str| {
+1 -1
View File
@@ -1264,7 +1264,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.smoothAuto",
"Auto-Smooth",
"",
"{} the open Smooth slider (Object › Path › Smooth and the canvas context menu's Smooth… open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open",
"{} the open Smooth slider (Object › Path › Smooth, the canvas context menu's Smooth… and the Contextual Task Bar's Smooth open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open",
),
(
"ui.simplifyMoreOptions",
+64 -28
View File
@@ -90,16 +90,20 @@ impl Exporter<'_> {
/// The stops of a gradient whose every stop is a tint of one spot ink (or paper white, 0% of
/// it) as tints of that ink — a Separation shading — with midpoints as explicit stops (the
/// tint and opacity there are halfway). A gradient that mixes a spot ink with other colors
/// gets [`Self::device_n_stops`]. `None` for gradients without a spot ink.
/// gets [`Self::device_n_stops`]. `None` for gradients without a spot ink that prints
/// ([`Self::printed_spot`]).
fn spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> {
let ink = g.stops.iter().find_map(|s| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)))?;
let drawn = g.drawn_stops();
let ink = g.stops.iter().zip(&drawn).find_map(|(s, &d)| self.printed_spot(s, d))?;
let tints: Option<Vec<f32>> = g
.stops
.iter()
.map(|s| match s.swatch.as_deref() {
.zip(&drawn)
.map(|(s, &d)| match s.swatch.as_deref() {
_ if !d => Some(0.0),
// A stop that also prints process plates needs a DeviceN space.
Some(n) if n == ink && s.plates.is_none() => Some(s.tint),
_ if self.paper_stop(s) => Some(0.0),
_ if self.inkless_stop(s) => Some(0.0),
_ => None,
})
.collect();
@@ -126,19 +130,22 @@ impl Exporter<'_> {
/// the order the stops name them), with midpoints as explicit stops (the tints and opacity
/// there are halfway), or for a Perceptual gradient the stops on its curve
/// ([`vectorcraft_color::Gradient::ink_mixes`]). Paper white adds no ink; a spot stop with
/// process plates ([`vectorcraft_color::GradientStop::plates`]) prints them too. `None`, with a
/// warning, when a stop is written in RGB or Lab or the spot inks can't share a DeviceN space;
/// the gradient is then written in the colors of its stops.
/// process plates ([`vectorcraft_color::GradientStop::plates`]) prints them too. Only the stops
/// that color something that draws ([`vectorcraft_color::Gradient::drawn_stops`]) name inks:
/// the others are written with no ink. `None`, with a warning, when a stop is written in RGB or
/// Lab or the spot inks can't share a DeviceN space; the gradient is then written in the colors
/// of its stops.
fn device_n_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> {
use krilla::color::device_n::{Color as DnColor, Colorant, DeviceNSpace, Process};
let doc = self.doc;
fn spot_of<'s>(doc: &vectorcraft_doc::Document, s: &'s vectorcraft_color::GradientStop) -> Option<&'s str> {
s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot))
}
let drawn = g.drawn_stops();
// Each spot ink: its swatch's name, then its colorant name and CMYK equivalent.
let mut spots: Vec<(&str, (String, [f32; 4]))> = Vec::new();
for s in &g.stops {
if let Some(n) = spot_of(doc, s)
for (s, &d) in g.stops.iter().zip(&drawn) {
if let Some(n) = self.printed_spot(s, d)
&& !spots.iter().any(|(m, _)| *m == n)
{
spots.push((n, self.spot_ink(n)?));
@@ -146,12 +153,15 @@ impl Exporter<'_> {
}
// Each stop's ink coverage: the four process inks, then one tint per spot ink.
let mut inks: Vec<Vec<f32>> = Vec::with_capacity(g.stops.len());
for s in &g.stops {
for (s, &d) in g.stops.iter().zip(&drawn) {
let mut v = vec![0.0; 4 + spots.len()];
match spot_of(doc, s) {
_ if !d => {}
Some(n) => {
let i = spots.iter().position(|(m, _)| *m == n)?;
v[4 + i] = s.tint;
// A spot ink that no drawn stop prints ([`Self::printed_spot`]) has no colorant.
if let Some(i) = spots.iter().position(|(m, _)| *m == n) {
v[4 + i] = s.tint;
}
if let Some(p) = s.plates {
v[..4].copy_from_slice(&p.map(|t| t.clamp(0.0, 1.0)));
}
@@ -434,18 +444,38 @@ impl Exporter<'_> {
!s.swatch.as_deref().is_some_and(|n| self.doc.swatch(n).is_some_and(|w| w.spot)) && paper(&s.color)
}
/// The spot swatch whose tints are every stop of `g` that isn't paper white
/// ([`Self::paper_stop`]); `None` when no stop is linked to a spot swatch or a stop has
/// another color.
fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> {
let spot = |s: &'g vectorcraft_color::GradientStop| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot));
let ink = g.stops.iter().find_map(spot)?;
g.stops.iter().all(|s| (spot(s) == Some(ink) && s.plates.is_none()) || self.paper_stop(s)).then_some(ink)
/// The spot swatch stop `s` prints: the one it is linked to, when the stop colors something
/// that draws (`drawn`, [`vectorcraft_color::Gradient::drawn_stops`]) at a tint above 0 or
/// with process plates ([`vectorcraft_color::GradientStop::plates`]), which print in the same
/// DeviceN space.
fn printed_spot<'s>(&self, s: &'s vectorcraft_color::GradientStop, drawn: bool) -> Option<&'s str> {
s.swatch.as_deref().filter(|n| drawn && (s.tint > 0.0 || s.plates.is_some()) && self.doc.swatch(n).is_some_and(|w| w.spot))
}
/// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): is a stop
/// linked to a spot swatch, and is every other stop paper white ([`Self::paper_stop`]) or
/// written in CMYK or gray?
/// Does stop `s` add no ink to a Separation or DeviceN shading: is it paper white
/// ([`Self::paper_stop`]) or linked to a spot swatch at a tint of 0 without process plates?
fn inkless_stop(&self, s: &vectorcraft_color::GradientStop) -> bool {
let spot = s.swatch.as_deref().is_some_and(|n| self.doc.swatch(n).is_some_and(|w| w.spot));
self.paper_stop(s) || (spot && self.printed_spot(s, true).is_none())
}
/// The spot swatch whose tints are every stop of `g` that colors something that draws
/// ([`vectorcraft_color::Gradient::drawn_stops`]) and adds ink ([`Self::inkless_stop`]); `None`
/// when no such stop prints a spot swatch ([`Self::printed_spot`]) or one has another color.
fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> {
let drawn = g.drawn_stops();
let ink = g.stops.iter().zip(&drawn).find_map(|(s, &d)| self.printed_spot(s, d))?;
g.stops
.iter()
.zip(&drawn)
.all(|(s, &d)| !d || (s.swatch.as_deref() == Some(ink) && s.plates.is_none()) || self.inkless_stop(s))
.then_some(ink)
}
/// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): does a stop
/// print a spot swatch ([`Self::printed_spot`]), and is every other stop that colors something
/// that draws linked to a spot swatch, paper white ([`Self::paper_stop`]) or written in CMYK or
/// gray?
fn device_n_inks_only(&self, g: &vectorcraft_color::Gradient) -> bool {
let doc = self.doc;
let cmyk_doc = doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
@@ -453,7 +483,9 @@ impl Exporter<'_> {
let inks = |s: &vectorcraft_color::GradientStop| {
self.paper_stop(s) || matches!(self.out.color(&s.color, self.intent, cmyk_doc, self.convert), Color::Cmyk { .. } | Color::Gray { .. })
};
g.stops.iter().any(spot) && g.stops.iter().all(|s| spot(s) || inks(s))
let drawn = g.drawn_stops();
let mut stops = g.stops.iter().zip(&drawn);
stops.clone().any(|(s, &d)| self.printed_spot(s, d).is_some()) && stops.all(|(s, &d)| !d || spot(s) || inks(s))
}
/// Is `g` a Perceptual linear or radial gradient of spot inks, alone or mixed with process
@@ -485,16 +517,20 @@ impl Exporter<'_> {
let doc = self.doc;
let full = doc.linked_color(doc.swatch(ink)?.paint.color()?, true);
let space = self.separation(ink)?;
// The stops keep their own tints; paper white is 0% of the ink.
let own: Vec<f32> = g.stops.iter().map(|s| if s.swatch.as_deref() == Some(ink) { s.tint } else { 0.0 }).collect();
// The stops keep their own tints; paper white, and a stop that colors nothing that draws,
// is 0% of the ink.
let own: Vec<f32> =
g.stops.iter().zip(g.drawn_stops()).map(|(s, d)| if d && s.swatch.as_deref() == Some(ink) { s.tint } else { 0.0 }).collect();
Some(g.ink_tints(&full, &own)?.into_iter().map(|(o, t, a)| (o, SepColor::new_f32(t, space.clone()).into(), a)).collect())
}
/// Warn when gradient `g`, drawn as an image, has stops linked to a spot swatch: the image is
/// in process colors, so the spot inks have no plate of their own.
/// Warn when gradient `g`, drawn as an image, has stops that print a spot swatch
/// ([`Self::printed_spot`]): the image is in process colors, so the spot inks have no plate of
/// their own.
fn warn_spot_image(&mut self, g: &vectorcraft_color::GradientPaint) {
let from_stops = g.gradient.kind != GradientKind::Freeform || !g.has_freeform_points();
if from_stops && self.has_spot_stop(&g.gradient) {
let prints_spot = || g.gradient.stops.iter().zip(g.gradient.drawn_stops()).any(|(s, d)| self.printed_spot(s, d).is_some());
if from_stops && prints_spot() {
self.warn(
"spot colors in gradients exported as images (freeform or dithered gradients, ones mixed around the hue wheel, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors",
);
+54
View File
@@ -278,6 +278,60 @@ fn spot_gradients_keep_a_trace_of_process_ink_at_a_light_stop() {
}
}
/// [`gold_gradient_pdf`] with spot swatch "Leaf" (C100 Y100) in the document too.
fn gold_and_leaf_gradient_pdf(stops: Vec<GradientStop>, perceptual: bool) -> String {
let mut d = doc(ColorMode::Cmyk, &[]);
d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true });
d.swatches.push(Swatch { name: "Leaf".into(), paint: Paint::solid(Color::cmyk(1.0, 0.0, 1.0, 0.0)), global: true, spot: true });
let mut g = Gradient::new(GradientKind::Linear, stops);
if perceptual {
g.method = GradientMethod::Perceptual;
}
let paint = Paint::Gradient(Box::new(GradientPaint::new(g)));
let l = d.layers[0].id;
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 30.0)), Appearance::basic(paint, Paint::None, 0.0));
d.insert(Some(l), 0, n).unwrap();
String::from_utf8_lossy(&export(&d, &PdfOptions::uncompressed()).unwrap()).into_owned()
}
#[test]
fn gradients_name_only_the_spot_inks_of_stops_that_color_what_draws() {
let black = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 1.0)) };
let paper = |offset: f32| GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 0.0));
let leaf = |offset: f32, tint: f32| {
let mut s = GradientStop::new(offset, Color::cmyk(1.0, 0.0, 1.0, 0.0).tinted(tint));
s.swatch = Some("Leaf".into());
s.tint = tint;
s
};
let clear_gold = GradientStop { opacity: 0.0, ..gold_stop(0.4, 1.0) };
for perceptual in [false, true] {
// Gold in a stretch where every stop is clear, and Gold out of order, where the stops at 0
// and 0.8 cover the stretches next to it: Gold colors nothing, so no Gold plate.
let clear = vec![black(0.0, 1.0), black(0.2, 0.0), clear_gold.clone(), black(0.6, 0.0), black(1.0, 1.0)];
let out_of_order = vec![black(0.0, 1.0), black(0.8, 1.0), gold_stop(0.2, 1.0), black(0.4, 1.0), black(1.0, 1.0)];
// Gold at a tint of 0 prints no Gold ink.
let no_tint = vec![gold_stop(0.0, 0.0), black(1.0, 1.0)];
for (what, stops) in [("clear", clear), ("out of order", out_of_order), ("tint 0", no_tint)] {
let text = gold_gradient_pdf(stops, perceptual);
assert!(!text.contains("/Gold"), "{what}, perceptual {perceptual}: {text}");
assert!(!text.contains("/DeviceN") && !text.contains("/Separation"), "{what}, perceptual {perceptual}: {text}");
}
// Without the Gold stop out of order, Leaf's tints over paper are a Separation shading.
let leaf_only = vec![leaf(0.0, 1.0), leaf(0.8, 0.5), gold_stop(0.2, 1.0), paper(0.4), paper(1.0)];
let text = gold_and_leaf_gradient_pdf(leaf_only, perceptual);
assert!(text.contains("[/Separation/Leaf/") && !text.contains("/Gold") && !text.contains("/DeviceN"), "perceptual {perceptual}: {text}");
// A clear Gold stop next to an opaque one colors the stretch between them.
let text = gold_gradient_pdf(vec![black(0.0, 1.0), clear_gold.clone(), black(1.0, 1.0)], perceptual);
assert!(text.contains("[/DeviceN[/Black/Gold]"), "perceptual {perceptual}: {text}");
}
// Gold at a tint of 0 over a 30% cyan plate (a DeviceN gradient read from a PDF) keeps its
// process plate in the same space.
let plated = GradientStop { plates: Some([0.3, 0.0, 0.0, 0.0]), ..gold_stop(1.0, 0.0) };
let text = gold_gradient_pdf(vec![GradientStop::new(0.0, Color::cmyk(0.3, 0.0, 0.0, 0.0)), plated], false);
assert!(text.contains("[/DeviceN[/Cyan/Gold]"), "{text}");
}
#[test]
fn converting_to_rgb_turns_cmyk_into_rg() {
let ink = Color::cmyk(0.0, 1.0, 1.0, 0.0);
+3 -3
View File
@@ -951,7 +951,8 @@ fn paints(ap: &Appearance, fill: bool) -> Vec<&Paint> {
}
/// `p` without swatch links: a plug-in's colours are its own, so only paints it returned
/// unchanged keep theirs.
/// unchanged keep theirs. A gradient stop also loses its process plates, which print only with
/// a spot swatch.
fn unlinked(mut p: Paint) -> Paint {
match &mut p {
Paint::Solid { swatch, tint, .. } => {
@@ -961,8 +962,7 @@ fn unlinked(mut p: Paint) -> Paint {
Paint::Gradient(g) => {
g.swatch = None;
for s in &mut g.gradient.stops {
s.swatch = None;
s.tint = 1.0;
s.set_color(s.color, None);
}
}
Paint::None | Paint::Pattern { .. } => {}
+61 -7
View File
@@ -109,27 +109,81 @@ fn only_a_radial_keeps_a_focal_point_through_a_plug_in() {
}
}
/// A gradient stop's process plates handed back out of range are clamped to 0..1, each in its
/// A gradient stop's process plates handed back out of range are read clamped to 0..1, each in its
/// place, in a fill and in a stroke (a JSON number too large for `f32` is infinite); plates in range
/// and a stop without plates are kept.
#[test]
fn gradient_stop_plates_are_clamped_through_a_plug_in() {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
let (mut d, ids, _) = doc();
let (_, ids, _) = doc();
let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(0.5, Color::gray(0.5)), GradientStop::new(1.0, Color::BLACK)];
let mut p = serde_json::to_value(Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))))).unwrap();
p["gradient"]["stops"][0]["plates"] = json!([-0.5, 1.5, -1e300, 1e300]);
p["gradient"]["stops"][1]["plates"] = json!([0.25, 2.0, 0.75, -3.0]);
for (i, plates) in [(0, json!([-0.5, 1.5, -1e300, 1e300])), (1, json!([0.25, 2.0, 0.75, -3.0]))] {
p["gradient"]["stops"][i]["plates"] = plates;
p["gradient"]["stops"][i]["swatch"] = json!("Spot");
}
let out = json!({"objects": [{"id": ids[0].0, "fills": [p.clone()], "strokes": [{"paint": p}]}]});
apply_output(&mut d, &ids, decode_json(out).unwrap(), None, &keep).unwrap();
let ap = &d.node(ids[0]).unwrap().appearance;
for (what, paint) in [("fill", ap.fill_paint()), ("stroke", ap.stroke_paint())] {
let out = decode_json(out).unwrap();
let fill = &out[0].fills.as_ref().unwrap()[0];
let stroke = &out[0].strokes.as_ref().unwrap()[0].paint;
for (what, paint) in [("fill", fill), ("stroke", stroke)] {
let Paint::Gradient(g) = paint else { panic!("a gradient {what}") };
let plates: Vec<_> = g.gradient.stops.iter().map(|s| s.plates).collect();
assert_eq!(plates, [Some([0.0, 1.0, 0.0, 1.0]), Some([0.25, 1.0, 0.75, 0.0]), None], "{what}");
}
}
/// A gradient linked to a gradient swatch, whose first stop is linked to a spot swatch at a tint
/// with process plates.
fn spot_gradient(color: Color) -> Paint {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
let mut spot = GradientStop::new(0.0, color);
spot.midpoint = 0.3;
spot.opacity = 0.75;
spot.set_color(color, Some(("Spot".into(), 0.4)));
spot.plates = Some([0.1, 0.2, 0.3, 0.4]);
let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![spot, GradientStop::new(1.0, Color::BLACK)]));
g.swatch = Some("Blend".into());
Paint::Gradient(Box::new(g))
}
/// A changed gradient handed back loses each stop's spot link, tint and process plates, in a fill
/// and in a stroke, and keeps the stop's color, offset, opacity and midpoint; a new stop handed back
/// with plates and no link has none.
#[test]
fn changed_gradient_fill_and_stroke_drop_spot_plates() {
let (mut d, ids, _) = doc();
let a = ids[0];
let was = Color::cmyk(0.0, 0.5, 1.0, 0.0);
d.node_mut(a).unwrap().appearance.set_fill(spot_gradient(was));
d.node_mut(a).unwrap().appearance.set_stroke(spot_gradient(was));
let now = Color::cmyk(0.0, 0.25, 0.5, 0.0);
let mut p = serde_json::to_value(spot_gradient(now)).unwrap();
p["gradient"]["stops"][1]["plates"] = json!([0.5, 0.5, 0.5, 0.5]);
let out = json!({"objects": [{"id": a.0, "fills": [p.clone()], "strokes": [{"paint": p}]}]});
apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &keep).unwrap();
let ap = &d.node(a).unwrap().appearance;
for (what, paint) in [("fill", ap.fill_paint()), ("stroke", ap.stroke_paint())] {
let Paint::Gradient(g) = paint else { panic!("a gradient {what}") };
assert_eq!(g.swatch, None, "{what}");
let stops: Vec<_> = g.gradient.stops.iter().map(|s| (s.offset, s.color, s.opacity, s.midpoint, s.swatch.clone(), s.tint, s.plates)).collect();
assert_eq!(stops, [(0.0, now, 0.75, 0.3, None, 1.0, None), (1.0, Color::BLACK, 1.0, 0.5, None, 1.0, None)], "{what}");
}
}
/// A gradient handed back as it was keeps its stops' spot links, tints and process plates.
#[test]
fn unchanged_gradient_paint_keeps_spot_plates() {
let (mut d, ids, _) = doc();
let a = ids[0];
d.node_mut(a).unwrap().appearance.set_fill(spot_gradient(Color::cmyk(0.0, 0.5, 1.0, 0.0)));
d.node_mut(a).unwrap().appearance.set_stroke(spot_gradient(Color::cmyk(0.0, 0.5, 1.0, 0.0)));
let input: Value = serde_json::from_slice(&encode_input(&d, &[a])).unwrap();
let before = d.clone();
apply_output(&mut d, &[a], decode(&serde_json::to_vec(&input).unwrap()).unwrap(), None, &keep).unwrap();
assert_eq!(d, before);
}
/// A plate that is not a number is 0; infinities go to the nearest end of 0..1.
#[test]
fn plates_that_are_not_finite_are_in_range() {
+20 -2
View File
@@ -623,7 +623,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b
let art = effects::evaluate_container(Some(symbols.document()), n);
let art = art.as_ref().unwrap_or(n);
let b = if art.clips() {
art.visual_bounds_with(symbols)
art.visual_bounds_with(symbols).map(|b| with_clip_region(symbols, art, b))
} else {
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, member(c)))
}?;
@@ -671,7 +671,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b
let b = n.visual_bounds_with(symbols)?;
let b = match &n.kind {
NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b),
_ => b,
_ => with_clip_region(symbols, n, b),
};
let a = live_content_reach(symbols, n);
Some(b.inflate(a, a))
@@ -679,6 +679,24 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b
}
}
/// `b`, the visual bounds of `n`, grown to the region a clip group's or clipping layer's clipping
/// path clips to when geometry effects in it move or grow that region ([`effects::clip_outline`])
/// past its plain shape, and to where its stroke paints along it. Other objects keep `b`.
fn with_clip_region(symbols: &SymbolBoxes, n: &Node, b: Rect) -> Rect {
match &n.kind {
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => match children.first() {
Some(c) if effects::needs_bake(c) => {
let s = c.appearance.outset();
let region = effects::clip_outline(c).map(|(bp, _)| bp.bounding_box().inflate(s, s));
let painted = if has_fx(c) { visual_bounds(symbols, c) } else { None };
vectorcraft_geom::union_opt(vectorcraft_geom::union_opt(Some(b), region), painted).unwrap_or(b)
}
_ => b,
},
_ => b,
}
}
/// The bounds of the art a 3D effect of `n` draws (Revolve, Extrude & Bevel, Inflate, Rotate),
/// built now, without `n`'s own raster effects ([`three_d_art_bounds`]): `None` when it draws no
/// such art, `Some(None)` when that art paints nothing. The renderer measures the art it keeps
+4 -1
View File
@@ -122,7 +122,8 @@ impl Renderer {
/// When a plug-in has been installed or removed since the last check
/// ([`effects::plugin_revision`]), drop what the caches keep from plug-in effects: the effect
/// geometry of paths with a plug-in effect, the cull bounds of objects that have one or hold one,
/// of symbol instances and of the objects that hold them, and the art drawn for the effects of
/// of symbol instances and of the objects that hold them, the regions of clipping paths that
/// have one or hold one, and the art drawn for the effects of
/// objects that have a plug-in effect or hold one. Rasters of raster effects and evaluations of live objects hold
/// the revision in their keys when what they draw has a plug-in effect (`fx::Inputs`,
/// `live::resources_key`).
@@ -148,6 +149,8 @@ impl Renderer {
self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node));
// The art of a group holds what its members' plug-in effects draw.
self.fx_arts.retain(|(node, ..)| !holds_plugin_effect(node));
// A clipping path's plug-in effects shape the region it clips to.
self.clips.retain(|(node, _)| !holds_plugin_effect(node));
}
}
+73
View File
@@ -95,6 +95,79 @@ fn nothing_to_clip_by_hides_the_art() {
assert_eq!(r.pixel(50, 50), CLEAR);
}
#[test]
fn the_clipping_paths_geometry_effects_shape_the_clip() {
// A Transform effect moving the left half 50 pt right: the right half shows instead.
let mut d = Document::new(100.0, 100.0);
let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
clip.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true });
let r = clipped(d, Arc::new(clip));
assert_eq!(r.pixel(25, 50), CLEAR, "the clipping path's own place");
assert_eq!(r.pixel(75, 50), BLACK, "where its effect moves it");
}
#[test]
fn a_view_of_only_the_moved_clip_draws_the_clipped_art() {
// The clipping path covers x 0 to 50 and its Transform effect moves it to x 50 to 100; the
// view shows x 60 to 100, past the clipping path's own shape.
let mut d = Document::new(100.0, 100.0);
let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
clip.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true });
let art = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let members = vec![Arc::new(clip), Arc::new(art)];
let g = Node::new(d.alloc_id(), NodeKind::Group { children: members.clone(), clip: true });
// The same in a sublayer that clips by its first object.
let mut sublayer = (*d.layers[0]).clone();
sublayer.id = d.alloc_id();
if let NodeKind::Layer { children, clip, .. } = &mut sublayer.kind {
(*children, *clip) = (members, true);
}
for clipping in [g, sublayer] {
let mut d = d.clone();
let l = d.layers[0].id;
d.insert(Some(l), 0, clipping).unwrap();
let r = Renderer::new().render(&d, 40, 100, Affine::translate((-60.0, 0.0)), &RenderOptions::default());
assert_eq!(r.pixel(20, 50), BLACK, "x 80, inside the moved clip");
}
}
#[test]
fn a_view_of_only_the_moved_clip_draws_a_clip_group_with_its_own_appearance() {
// As above, with the Transform effect on the clipping path itself or on its fill, and the clip
// group with an effect of its own: a Drop Shadow, or a Transform that moves nothing.
let moved = || vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true };
let shadow = vectorcraft_doc::Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 0, "y": 6, "blur": 0, "opacity": 100}),
visible: true,
};
let still = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveV": 0}), visible: true };
for on_fill in [false, true] {
for own in [shadow.clone(), still.clone()] {
let mut d = Document::new(100.0, 100.0);
let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
match clip.appearance.items.iter_mut().find_map(|i| match i {
vectorcraft_doc::AppearanceItem::Fill(f) if on_fill => Some(f),
_ => None,
}) {
Some(f) => f.effects.push(moved()),
None => clip.appearance.effects.push(moved()),
}
let art =
Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let mut g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true });
g.appearance.effects.push(own.clone());
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let what = format!("effect on the fill: {on_fill}, group effect {}", own.id);
let whole = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(whole.pixel(80, 50), BLACK, "the whole canvas ({what})");
let r = Renderer::new().render(&d, 40, 100, Affine::translate((-60.0, 0.0)), &RenderOptions::default());
assert_eq!(r.pixel(20, 50), BLACK, "x 80, inside the moved clip ({what})");
}
}
}
#[test]
fn clip_regions_are_cached_per_clipping_path() {
let mut d = Document::new(100.0, 100.0);
+3 -3
View File
@@ -217,9 +217,9 @@ fn type_in_a_group_keeps_its_transparency_and_visibility_under_the_groups_geomet
#[test]
fn type_in_a_clipping_path_clips_alike_under_the_groups_geometry_effects() {
// The red square is clipped by type with a Transform of its own, which the clip leaves out,
// then by a group of that type and a small square, with the type shown and hidden. Offset
// Path 0 on the clip group leaves every path as it is.
// The red square is clipped by type with a Transform of its own, then by a group of that type
// and a small square, with the type shown and hidden. Offset Path 0 on the clip group leaves
// every path as it is, so the clip group draws the same with it and without it.
let doc = |effect: bool, grouped: bool, hidden: bool| {
let mut d = Document::new(100.0, 100.0);
let style = CharStyle { size: 40.0, fill: Paint::None, ..Default::default() };
+10
View File
@@ -1667,6 +1667,16 @@ fn objects_with_plugin_effects_follow_the_plugins_installed() {
plugin_change_draws_as_new(alone(20.0, vec![drop_shadow()]), ID, small.clone(), big.clone());
let blur = effect("blur.gaussian", serde_json::json!({"radius": 1.0}));
plugin_change_draws_as_new(alone(20.0, vec![blur]), ID, small.clone(), big.clone());
// A clip group whose clipping path, the square, has the plug-in effect: it clips a red
// rectangle over the whole view by the shape the plug-in draws.
let mut d = Document::new(200.0, 100.0);
let mut clip = square(&mut d, 20.0, 30.0, vec![]);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let red = filled(&mut d, rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), vec![]);
let group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(red)], clip: true });
plugin_change_draws_as_new(with_art(d, vec![group]), ID, small.clone(), big.clone());
// An instance of symbol `Shaped`, a group of the square around the origin.
let mut d = Document::new(200.0, 100.0);
let art = square(&mut d, -20.0, -20.0, vec![]);
+12 -4
View File
@@ -1425,10 +1425,18 @@ impl Ctx {
let em = self.font_size(tp);
let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0];
let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 };
// Text that would start before the path's beginning starts at it, with all its glyphs
// on the path (SVG does not draw the glyphs that fall off it).
let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(0.0, 1.0));
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start, end: None };
// On a path of one closed subpath, text that starts before the path's start or past its
// end wraps around the path. Otherwise it starts at the path's start or end.
let path = PathData::from_bezpath(&bp);
let wraps = path.subpaths.len() == 1 && path.is_closed();
let wrap = |s: f64| {
// `rem_euclid` can round a tiny negative value up to 1.
let s = s.rem_euclid(1.0);
if s < 1.0 { s } else { 0.0 }
};
let start =
Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| if wraps { wrap(s) } else { s.clamp(0.0, 1.0) });
obj.kind = TextKind::OnPath { path, start, end: None };
obj.xf = Affine::IDENTITY;
} else if vertical {
// Type on a vertical path: dy is extra advance, dx shifts across the column.
+31
View File
@@ -102,6 +102,37 @@ fn a_strokes_own_glow_filters_the_stroke_element_only() {
assert!(!export(&doc_with(n), &ExportOptions::default()).contains("<filter "));
}
#[test]
fn a_clipping_paths_transform_effect_moves_the_written_clip_path() {
// A clip group whose clipping path covers x 0 to 50; `effect` moves it 50 pt right.
let clipped = |x0: f64, effect: bool| {
let mut clip =
Node::path(NodeId(0), shapes::rectangle(Rect::new(x0, 0.0, x0 + 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
if effect {
clip.appearance.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 50}), visible: true });
}
let art = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
let mut d = Document::new(200.0, 200.0);
let (clip, art) = (Node { id: d.alloc_id(), ..clip }, Node { id: d.alloc_id(), ..art });
let g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![std::sync::Arc::new(clip), std::sync::Arc::new(art)], clip: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, g).unwrap();
let svg = export(&d, &ExportOptions::default());
let open = svg.find("<clipPath").expect("a clip path");
let body = &svg[open..open + svg[open..].find("</clipPath>").expect("its end")];
body[body.find('>').unwrap()..].to_string()
};
assert_eq!(clipped(0.0, true), clipped(50.0, false), "the clip path is written where the effect moves it");
assert_ne!(clipped(0.0, true), clipped(0.0, false));
}
#[test]
fn geometry_effects_on_type_export_as_its_reshaped_outlines() {
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
+78
View File
@@ -299,6 +299,84 @@ fn text_path_becomes_type_on_a_path() {
assert_eq!(t[2].para.justify, Justify::Left);
}
#[test]
fn text_on_a_closed_path_wraps_past_the_path_start() {
// A closed path 400 long: text anchored before its start, or offset past its end, wraps
// around the path instead of stopping at its start or end.
let d = open(
r##"<path id="p" d="M50 50 H150 V150 H50 Z" fill="none" stroke="black"/>
<text font-size="20" text-anchor="end"><textPath href="#p">Wave</textPath></text>
<text font-size="20"><textPath href="#p" startOffset="440">Wave</textPath></text>
<text font-size="20" direction="rtl"><textPath href="#p">Wave</textPath></text>
<text font-size="20" text-anchor="middle"><textPath href="#p">Wave</textPath></text>
<text font-size="20"><textPath href="#p" startOffset="-40">Wave</textPath></text>
<text font-size="20"><textPath href="#p" startOffset="-10%">Wave</textPath></text>
<path id="q" d="M50 50 H150 V150 H50 Z M200 50 H300 V150 H200 Z" fill="none" stroke="black"/>
<text font-size="20" text-anchor="end"><textPath href="#q">Wave</textPath></text>
<path id="o" d="M50 50 H150 V150 H50" fill="none" stroke="black"/>
<text font-size="20" text-anchor="end"><textPath href="#o">Wave</textPath></text>
<text font-size="20"><textPath href="#o" startOffset="440">Wave</textPath></text>"##,
);
let t = texts(&d);
assert_eq!(t.len(), 9);
let start_of = |t: &TextObject| match t.kind {
TextKind::OnPath { start, .. } => start,
_ => panic!("{:?}", t.kind),
};
let width: f64 = layout(FontDb::global(), t[0]).glyphs.iter().map(|g| g.advance).sum();
assert!(width > 0.0);
// Ending at the path's start: the text starts its width before the end of the loop.
assert!(close(start_of(t[0]), 1.0 - width / 400.0, 1e-6), "{} {width}", start_of(t[0]));
assert!(close(start_of(t[1]), 0.1, 1e-9), "{}", start_of(t[1]));
// Right-to-left text starts at its edge toward the path's end, here the path's start.
assert!(close(start_of(t[2]), 1.0 - width / 400.0, 1e-6), "{} {width}", start_of(t[2]));
// Centered on the path's start: half the text before it, half after.
assert!(close(start_of(t[3]), 1.0 - width / 800.0, 1e-6), "{} {width}", start_of(t[3]));
// A negative startOffset, in user units or as a percentage, counts back from the path's end.
assert!(close(start_of(t[4]), 0.9, 1e-9), "{}", start_of(t[4]));
assert!(close(start_of(t[5]), 0.9, 1e-9), "{}", start_of(t[5]));
// A path of two closed subpaths, and an open path, do not wrap: the text starts at the path's
// start or end.
assert_eq!(start_of(t[6]), 0.0);
assert_eq!(start_of(t[7]), 0.0);
assert_eq!(start_of(t[8]), 1.0);
// Laid out, the end-anchored text draws every glyph up the left side and ends at the start
// point; the centered text crosses the start point from the left side onto the top.
let lay = layout(FontDb::global(), t[0]);
assert!(!lay.overflow);
assert_eq!(lay.glyphs.len(), 4);
for g in &lay.glyphs {
let o = t[0].xf * g.origin;
assert!(close(o.x, 50.0, 0.05) && o.y > 50.0 && o.y < 150.0, "{o:?}");
}
let last = lay.glyphs.last().map(|g| t[0].xf * (g.origin + baseline_dir(g.angle) * g.advance));
assert!(last.is_some_and(|p| close(p.x, 50.0, 0.05) && close(p.y, 50.0, 0.05)), "{last:?}");
let lay = layout(FontDb::global(), t[3]);
assert!(!lay.overflow);
assert_eq!(lay.glyphs.len(), 4);
let first = t[3].xf * lay.glyphs[0].origin;
assert!(close(first.x, 50.0, 0.05) && first.y > 50.0, "{first:?}");
let last = t[3].xf * lay.glyphs[3].origin;
assert!(close(last.y, 50.0, 0.05) && last.x > 50.0, "{last:?}");
// Text longer than the loop draws its first characters, whatever its anchor.
let d = open(
r##"<path id="s" d="M0 0 H20 V20 H0 Z" fill="none" stroke="black"/>
<text font-size="20" text-anchor="end"><textPath href="#s">Wave Wave Wave</textPath></text>"##,
);
let t = only_text(&d);
let lay = layout(FontDb::global(), t);
assert!(lay.overflow);
assert!(!lay.glyphs.is_empty() && lay.glyphs.len() < 14, "{}", lay.glyphs.len());
assert!(lay.glyphs.iter().enumerate().all(|(i, g)| g.byte == i), "{:?}", lay.glyphs.iter().map(|g| g.byte).collect::<Vec<_>>());
}
/// The unit vector along a glyph's baseline.
fn baseline_dir(angle: f64) -> vectorcraft_geom::Vec2 {
vectorcraft_geom::Vec2::new(angle.cos(), angle.sin())
}
#[test]
fn per_character_positions_rotation_and_shifts() {
let d = open(r#"<text x="10 30 60" y="50" rotate="15 -30" dy="0 -4" font-size="12">abcd</text>"#);
+35 -3
View File
@@ -36,7 +36,8 @@ pub struct Targets {
pub(super) xs: Vec<(f64, Point, Kind)>,
pub(super) ys: Vec<(f64, Point, Kind)>,
/// Path segments and their control bounds, which a point lands on ("path"): gathered only for
/// dragged anchors ([`Self::for_anchor_drag`]).
/// dragged anchors ([`Self::for_anchor_drag`]) and selections moved by an anchor
/// ([`Self::for_move_by_anchor`]).
pub(super) segments: Vec<(Rect, PathSeg)>,
/// Ruler guides, which pull a point into line where they run (Snap to Point).
pub(super) rulers: Vec<Ruler>,
@@ -170,7 +171,18 @@ impl Targets {
/// Guides › Snap to Last Location, a move (not a copy, which has nothing in `exclude`) goes back
/// to where it began when it comes near ([`Self::snap_back`]), unless Snap to Pixel snaps moves.
pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self {
let mut t = Self::collect(cx.doc, exclude, None).styled(cx);
Self::moving(cx, exclude, Gather::Bounds)
}
/// [`Self::for_move`]'s targets for a selection moved by one of its anchors (#7411): the path
/// segments of the art but `exclude` too, which that anchor lands on ([`Self::snap_onto_path`]),
/// as a dragged anchor does with Direct Selection.
pub fn for_move_by_anchor(cx: &ToolContext, exclude: &[NodeId]) -> Self {
Self::moving(cx, exclude, Gather::Segments)
}
fn moving(cx: &ToolContext, exclude: &[NodeId], gather: Gather) -> Self {
let mut t = Self::collect_inner(cx.doc, exclude, None, None, gather).styled(cx);
if cx.spacing_guides {
t.boxes = object_boxes(cx.doc, &cx.selection.objects);
}
@@ -858,6 +870,21 @@ impl Targets {
self.line(from, q).into_iter().chain(self.label(q, "align")).collect()
}
/// A point `p` near no target anchor or center landed where two target paths cross
/// ("intersect"), else on the nearest target path ("path"), within `tol`: the point and its
/// label, as [`Self::snap_point`] lands it. The ruler guides of `rulers` count as paths that
/// cross ([`Self::rulers`]). None when no path is in reach, or the targets hold no segments.
pub(crate) fn snap_onto_path(&self, p: Point, tol: f64, rulers: Option<&Targets>) -> Option<(Point, Vec<Overlay>)> {
if self.segments.is_empty() {
return None;
}
if let Some(q) = self.crossing_with(p, tol, rulers.map_or(&self.rulers, |r| &r.rulers)) {
return Some((q, self.label(q, "intersect").into_iter().collect()));
}
let q = self.on_segment(p, tol)?;
Some((q, self.label(q, Kind::Edge.label()).into_iter().collect()))
}
/// The nearest point within `tol` of `p` on a target segment.
fn on_segment(&self, p: Point, tol: f64) -> Option<Point> {
self.segments
@@ -872,6 +899,11 @@ impl Targets {
/// Where two target paths, a path and a ruler guide, or two ruler guides cross, the crossing
/// nearest `p` within `tol` ("intersect", #1602).
fn crossing(&self, p: Point, tol: f64) -> Option<Point> {
self.crossing_with(p, tol, &self.rulers)
}
/// [`Self::crossing`] with the ruler guides `rulers`.
fn crossing_with(&self, p: Point, tol: f64, rulers: &[Ruler]) -> Option<Point> {
// A ruler guide running past, as a short line through the reach of `p`.
let ruler = |r: &Ruler| {
let (pos, ..) = r.line_at(p, tol)?;
@@ -885,7 +917,7 @@ impl Targets {
.iter()
.filter(|(b, c)| b.inflate(tol, tol).contains(p) && c.nearest(p, 1e-6).distance_sq <= tol * tol)
.map(|(_, c)| *c)
.chain(self.rulers.iter().filter_map(ruler))
.chain(rulers.iter().filter_map(ruler))
// shortcut: the first 32 lines in reach are paired, raise it if busier crossings matter
.take(32)
.collect();
+52 -16
View File
@@ -94,6 +94,9 @@ enum State {
/// The point that snaps, moving with the pointer: `start`, or where a press on a path's
/// stroke or beside its edge took hold of the path ([`hold_point`]).
hold: Point,
/// The press grabbed the selection by an anchor (`start`): it snaps as
/// [`MoveSnap::by_anchor`] says.
by_anchor: bool,
began: bool,
/// Shift-pressed on this selected object: released without a drag, it leaves the
/// selection.
@@ -247,13 +250,24 @@ pub(crate) struct MoveSnap {
impl MoveSnap {
pub(crate) fn new(cx: &ToolContext) -> Self {
Self::gather(cx, Targets::for_move)
}
/// [`Self::new`] for a selection grabbed by one of its anchors: with Smart Guides, that anchor
/// also lands on the other art's paths and where they cross (#7411), as Direct Selection lands
/// an anchor it drags.
pub(crate) fn by_anchor(cx: &ToolContext) -> Self {
Self::gather(cx, Targets::for_move_by_anchor)
}
fn gather(cx: &ToolContext, for_move: fn(&ToolContext, &[NodeId]) -> Targets) -> Self {
let guides = cx.smart_guides && !cx.snap_to_grid;
let points = |exclude: &[NodeId]| if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, exclude) };
Self {
bounds: selection_bounds(cx),
targets: guides.then(|| Targets::for_move(cx, &cx.selection.objects)),
targets: guides.then(|| for_move(cx, &cx.selection.objects)),
points: points(&cx.selection.objects),
copy_targets: guides.then(|| Targets::for_move(cx, &[])),
copy_targets: guides.then(|| for_move(cx, &[])),
copy_points: points(&[]),
rulers: (guides && cx.snap_to_point).then(|| Targets::rulers(cx)),
}
@@ -286,6 +300,9 @@ impl MoveSnap {
}
// The axes (x, y) Smart Guides already put in line with other art.
let mut taken = [false; 2];
// The grabbed anchor landed on a path or a crossing, which a ruler guide does not pull
// it off.
let mut on_path = false;
if let (Some(t), Some(b)) = (targets, self.bounds) {
// The point the selection was grabbed by, near another object's anchor or centre,
// lands on it, rather than the bounds lining up a little beside it (#955).
@@ -294,6 +311,13 @@ impl MoveSnap {
d = q - start;
guides = ov;
taken = [true; 2];
} else if let Some((q, ov)) = t.snap_onto_path(start + d, cx.snap_tol(), self.rulers.as_ref()) {
// Grabbed by an anchor ([`Self::by_anchor`]), it lands on a path near it, or where
// the path crosses another or a ruler guide, instead of lining the bounds up.
d = q - start;
guides = ov;
taken = [true; 2];
on_path = true;
} else {
let (adj, ov, axes) = t.snap_rects_axes(&[b + d], cx.snap_tol());
d += adj;
@@ -304,7 +328,7 @@ impl MoveSnap {
// A ruler guide within reach of the grabbed point wins over lining the bounds up; on an
// axis without one that Smart Guides left free, the bounds line up with the ruler guides
// (#5746), alongside the Smart Guides.
if let Some(t) = &self.rulers {
if let Some(t) = self.rulers.as_ref().filter(|_| !on_path) {
let (adj, ov, held) = t.snap_moved_to_rulers(start + d, self.bounds.map(|b| b + d), cx.snap_tol(), taken);
if adj != Vec2::ZERO {
d += adj;
@@ -681,6 +705,15 @@ impl SelectionTool {
})
}
/// The point a press `ev` grabs the selection by ([`Self::hover_point`]), and whether it is an
/// anchor. Nothing for other pointer events.
fn grabbed_at(cx: &ToolContext, ev: &PointerEvent) -> (Option<Point>, bool) {
match ev.kind {
PointerKind::Down => Self::hover_point(cx, ev.pos).map_or((None, false), |(q, label)| (Some(q), label == "anchor")),
_ => (None, false),
}
}
/// On a press, notes the text the pointer is loaded with ([`Self::loaded_at_press`]); a
/// release clears it.
fn note_loaded_at_press(&mut self, kind: PointerKind) {
@@ -913,7 +946,7 @@ impl Tool for SelectionTool {
let m = ev.mods;
// A press near a centre or an anchor grabs the object by it.
self.hover = None;
let grabbed = if matches!(ev.kind, PointerKind::Down) { Self::hover_point(cx, p).map(|(q, _)| q) } else { None };
let (grabbed, by_anchor) = Self::grabbed_at(cx, ev);
let grab = grabbed.unwrap_or(p);
if matches!(ev.kind, PointerKind::Move) && matches!(self.state, State::Idle) {
self.hover = Self::hover_point(cx, p);
@@ -939,7 +972,7 @@ impl Tool for SelectionTool {
{
let several = cx.selection.objects.len() > 1 || !cx.selection.guides.is_empty();
let (deselect, key) = if m.shift { (Some(id), None) } else { (None, (several && !m.alt).then_some(id)) };
self.state = State::Moving { start: p, press: p, hold: center, began: false, deselect, key, exclude: None };
self.state = State::Moving { start: p, press: p, hold: center, by_anchor: false, began: false, deselect, key, exclude: None };
return vec![];
}
// 3. Objects: Cmd/Ctrl-click selects the one under the selected one (cycling).
@@ -947,7 +980,8 @@ impl Tool for SelectionTool {
&& cx.select_behind
&& let Some(behind) = object_behind(cx, p)
{
self.state = State::Moving { start: p, press: p, hold: p, began: false, deselect: None, key: None, exclude: None };
self.state =
State::Moving { start: p, press: p, hold: p, by_anchor: false, began: false, deselect: None, key: None, exclude: None };
return vec![Action::Exec("select.set".into(), json!({ "ids": [behind.0] }))];
}
let (hit, scope) = match selected_source_hit(cx, p) {
@@ -969,17 +1003,17 @@ impl Tool for SelectionTool {
// Grabbed by no anchor or centre, a press on a path's stroke or beside its
// edge holds the path there, and that point of it snaps (#3701).
let hold = if grabbed.is_some() { grab } else { hold_point(cx, &h, p) };
self.state = State::Moving { start: grab, press: p, hold, began: false, deselect, key, exclude };
self.state = State::Moving { start: grab, press: p, hold, by_anchor, began: false, deselect, key, exclude };
out
}
None => {
self.press_nothing(cx, p, grab, m);
self.press_nothing(cx, p, grab, by_anchor, m);
vec![]
}
}
}
(PointerKind::Drag, State::Moving { start, press, hold, began, exclude, .. }) => {
self.drag_moving(cx, p, m, ev.backquote, (start, press, hold, began), exclude)
(PointerKind::Drag, State::Moving { start, press, hold, by_anchor, began, exclude, .. }) => {
self.drag_moving(cx, p, m, ev.backquote, (start, press, hold, by_anchor, began), exclude)
}
(PointerKind::Drag, State::Scaling { handle, bx, areas }) => {
self.last_backquote = ev.backquote;
@@ -1679,9 +1713,10 @@ impl SelectionTool {
/// A press at `p` that hits no object starts a marquee, except where a clipping mask hides
/// the art of a selected object (picked in the Layers panel, say): there, without Shift, it
/// grabs the selection by that art (at `grab`), as a press on its visible part does.
fn press_nothing(&mut self, cx: &ToolContext, p: Point, grab: Point, m: Mods) {
/// `by_anchor`: `grab` is an anchor of it.
fn press_nothing(&mut self, cx: &ToolContext, p: Point, grab: Point, by_anchor: bool, m: Mods) {
self.state = if !m.shift && selected_hidden_at(cx.doc, p, cx.selection_hit_options(), &cx.selection.objects).is_some() {
State::Moving { start: grab, press: p, hold: grab, began: false, deselect: None, key: None, exclude: None }
State::Moving { start: grab, press: p, hold: grab, by_anchor, began: false, deselect: None, key: None, exclude: None }
} else {
State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false, directional_inside: false }
};
@@ -1693,7 +1728,7 @@ impl SelectionTool {
p: Point,
m: Mods,
backquote: bool,
(start, press, hold, began): (Point, Point, Point, bool),
(start, press, hold, by_anchor, began): (Point, Point, Point, bool, bool),
exclude: Option<(NodeId, NodeId)>,
) -> Vec<Action> {
let mut out = vec![];
@@ -1701,6 +1736,7 @@ impl SelectionTool {
if p.distance(press) < Self::drag_threshold(cx) {
return out;
}
let gather = if by_anchor { MoveSnap::by_anchor } else { MoveSnap::new };
// A Shift-drag from a highlighted object of the Global Edit selects it and moves it
// with the selection: its snap targets are gathered for the selection with it.
self.moving = Some(match exclude {
@@ -1708,9 +1744,9 @@ impl SelectionTool {
out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 })));
let mut selection = cx.selection.clone();
selection.add(top);
MoveSnap::new(&ToolContext { selection: &selection, ..*cx })
gather(&ToolContext { selection: &selection, ..*cx })
}
None => MoveSnap::new(cx),
None => gather(cx),
});
out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() }));
}
@@ -1721,7 +1757,7 @@ impl SelectionTool {
}
// Shift has already constrained `d`: any change is a snap.
self.snapped = (d - unsnapped).hypot() > 1e-9;
self.state = State::Moving { start, press, hold, began: true, deselect: None, key: None, exclude: None };
self.state = State::Moving { start, press, hold, by_anchor, began: true, deselect: None, key: None, exclude: None };
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y)));
let mut params = json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt });
if backquote {
+88
View File
@@ -171,6 +171,94 @@ fn a_move_grabbed_by_an_anchor_lands_it_on_another_anchor() {
assert!((200.0 + x - 350.3).abs() < 1e-9 && (200.0 + y - 250.0).abs() < 1e-9, "the corner lands at ({}, {})", 200.0 + x, 200.0 + y);
}
/// A document with the rectangle of [`doc_with_rect`] (100..200 square) and a straight path from
/// `a` to `b` for each pair, and the rectangle's id.
fn doc_with_rect_and_lines(lines: &[(Point, Point)]) -> (vectorcraft_doc::Document, NodeId) {
let (mut d, id) = doc_with_rect();
let l = d.layers[0].id;
for &(a, b) in lines {
let n = d.alloc_id();
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[a, b], false));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(n, path, vectorcraft_doc::Appearance::default_art())).unwrap();
}
(d, id)
}
/// The rectangle of `d` grabbed at `(gx, gy)` (hovered first, so a press near an anchor grabs it)
/// and dragged to `to`, with Smart Guides on or off (`smart`): the point it was grabbed at after the
/// move, and the label shown.
fn grabbed_and_dragged(d: &vectorcraft_doc::Document, id: NodeId, smart: bool, (gx, gy): (f64, f64), to: (f64, f64)) -> (Point, Option<String>) {
let (none, mut picked, p) = (Selection::default(), Selection::default(), paint());
picked.add(id);
let (before, after) = (ToolContext { smart_guides: smart, ..cx(d, &none, &p) }, ToolContext { smart_guides: smart, ..cx(d, &picked, &p) });
let mut t = SelectionTool::default();
t.pointer(&before, &ev(PointerKind::Move, gx, gy));
t.pointer(&before, &ev(PointerKind::Down, gx, gy));
let a = t.pointer(&after, &ev(PointerKind::Drag, to.0, to.1));
let Some(Action::Preview(_, v)) = a.iter().find(|a| matches!(a, Action::Preview(..))) else { panic!("{a:?}") };
let start = SelectionTool::hover_point(&before, Point::new(gx, gy)).map_or(Point::new(gx, gy), |(q, _)| q);
let label = t.overlays(&after).iter().find_map(|o| match o {
Overlay::Label { text, .. } => Some(text.clone()),
_ => None,
});
(start + Vec2::new(v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()), label)
}
/// #7411: grabbed by an anchor and dropped near a point along another path, away from its anchors
/// and center, the anchor lands on the path ("path") with Smart Guides. A selection grabbed
/// elsewhere, or with Smart Guides off, does not land on the path.
#[test]
fn a_move_grabbed_by_an_anchor_lands_it_on_another_path() {
let (a, b) = (Point::new(300.0, 400.0), Point::new(500.0, 300.0));
let (d, id) = doc_with_rect_and_lines(&[(a, b)]);
let off_line = |q: Point| ((b - a).cross(q - a) / (b - a).hypot()).abs();
// Grab the rectangle by its bottom-right corner (200, 200) and drop it half a point beside
// the line, at (430, 335) on it.
let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (430.0, 335.5));
assert!(off_line(corner) < 1e-9, "the corner lands at {corner:?}, {} off the line", off_line(corner));
assert!((corner.x - 430.0).abs() < 0.5, "{corner:?}");
assert_eq!(label.as_deref(), Some("path"));
// Grabbed by its fill instead, the pointer stays half a point beside the line.
let (grip, _) = grabbed_and_dragged(&d, id, true, (150.0, 170.0), (430.0, 335.5));
assert!(off_line(grip) > 0.4, "the grip lands at {grip:?}");
// With Smart Guides off, the corner stays where it was dragged.
let (corner, _) = grabbed_and_dragged(&d, id, false, (201.0, 201.0), (430.0, 335.5));
assert!(off_line(corner) > 0.4, "the corner lands at {corner:?}");
}
/// #7411: a selection grabbed by an anchor lands it where two other paths cross ("intersect"), and
/// on another object's anchor near the path rather than on the path.
#[test]
fn a_move_grabbed_by_an_anchor_lands_it_where_paths_cross() {
let (d, id) =
doc_with_rect_and_lines(&[(Point::new(300.0, 400.0), Point::new(500.0, 300.0)), (Point::new(460.0, 250.0), Point::new(460.0, 420.0))]);
let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (460.4, 320.5));
assert!(corner.distance(Point::new(460.0, 320.0)) < 1e-9, "the corner lands at {corner:?}");
assert_eq!(label.as_deref(), Some("intersect"));
// The end of the upright path (460, 250) is an anchor: the corner lands on it.
let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (460.5, 251.0));
assert!(corner.distance(Point::new(460.0, 250.0)) < 1e-9, "the corner lands at {corner:?}");
assert_eq!(label.as_deref(), Some("anchor"));
}
/// #7411: a selection grabbed by an anchor lands it where another path crosses a ruler guide
/// ("intersect"), and a ruler guide running past does not pull it off a path it landed on.
#[test]
fn a_move_grabbed_by_an_anchor_lands_it_where_a_path_crosses_a_ruler_guide() {
let (a, b) = (Point::new(300.0, 400.0), Point::new(500.0, 300.0));
let (mut d, id) = doc_with_rect_and_lines(&[(a, b)]);
d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 440.0));
let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (440.5, 330.5));
assert!(corner.distance(Point::new(440.0, 330.0)) < 1e-9, "the corner lands at {corner:?}");
assert_eq!(label.as_deref(), Some("intersect"));
// Beyond reach of the crossing, the corner stays on the path with the guide 3.7 points beside
// it.
let off_line = |q: Point| ((b - a).cross(q - a) / (b - a).hypot()).abs();
let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (436.43, 332.12));
assert!(off_line(corner) < 1e-9, "the corner lands at {corner:?}");
assert_eq!(label.as_deref(), Some("path"));
}
/// #3716: a press a few pixels from an anchor grabs the object by that anchor, but the drag
/// threshold counts from where the pointer went down. A click that wobbles by a pixel selects
/// the object without moving it, instead of jumping the anchor onto the pointer.
+1
View File
@@ -457,5 +457,6 @@ impl VectorcraftApp {
/// Show a transient status message.
pub fn status(&mut self, s: impl Into<String>) {
self.ui.status = s.into();
self.ui.status_sets = self.ui.status_sets.wrapping_add(1);
}
}
+32 -11
View File
@@ -207,8 +207,8 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors", none()));
items.push((tl!("Connect"), "dc-join", "path.join", none()));
items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors", none()));
items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none()));
items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none()));
items.push((tl!("Convert Selected Anchor Points to Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none()));
items.push((tl!("Convert Selected Anchor Points to Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none()));
}
// As in the reference app, open paths offer Join and Edit Path, compound paths and clipping
// masks their Release, images selected with vector art Make Clipping Mask, type converts
@@ -257,14 +257,17 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
} else if is_image {
// Offset Path and Simplify don't apply to an image.
} else if !anchors && direct {
// Corner and Smooth convert every anchor of a path selected whole; Remove and Cut
// need anchors picked out, so they wait for them.
// The two convert buttons convert every anchor of a path selected whole; Remove and Cut
// need anchors picked out, so they wait for them. Simplify and Smooth open their
// sliders, as Object › Path › Simplify… and Smooth… do; the page above names Smooth as
// adjusting the path's curvature, apart from converting its anchor points.
if open_path {
items.push((tl!("Connect"), "dc-join", "path.join", none()));
}
items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none()));
items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none()));
items.push((tl!("Convert Selected Anchor Points to Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none()));
items.push((tl!("Convert Selected Anchor Points to Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none()));
items.push((tl!("Simplify"), "spline", "object.path.simplify", none()));
items.push((tl!("Smooth"), "dc-smooth", "object.path.smooth", none()));
} else if !anchors {
if open_path {
items.push((tl!("Join"), "dc-join", "path.join", none()));
@@ -292,7 +295,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
.into_iter()
.filter_map(|(shown, chip)| shown.then_some(chip))
.collect();
let widths: Vec<f32> = items.iter().map(|(label, ..)| action_width(ui, label)).collect();
let widths: Vec<f32> = items.iter().map(|(label, _, cmd, _)| if icon_only(cmd) { BAR_MORE } else { action_width(ui, label) }).collect();
let font_w = fonts.then(|| {
let w = crate::panels::character::TASK_BAR_WIDTHS;
w.iter().sum::<f32>() + BAR_GAP * (w.len() - 1) as f32
@@ -367,7 +370,17 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
}
}
for ((label, icon, cmd, params), at) in items.iter().zip(&layout.actions) {
if *at == Some(row) && action_button(ui, &t, label, icon) {
if *at != Some(row) {
continue;
}
// The convert buttons show their icon alone, named by their hover text, as
// the Control bar's do.
let clicked = if icon_only(cmd) {
widgets::icon_button(ui, icon, label, false, BAR_MORE).clicked()
} else {
action_button(ui, &t, label, icon)
};
if clicked {
run = Some((*cmd, params.clone()));
}
}
@@ -459,7 +472,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
Some((TASK_BAR_ARTBOARD_OPTIONS, _)) => crate::menus::invoke(app, "tool.options", json!({"tool": "artboard"})),
Some((TASK_BAR_DUPLICATE_ARTBOARDS, _)) => crate::panels::artboards::duplicate_picked(app),
Some((TASK_BAR_REARRANGE_ARTBOARDS, _)) => crate::panels::artboards::open_rearrange(app),
// Corner and Smooth convert the direct-selected anchors, as the Control bar's buttons do.
// The convert buttons convert the direct-selected anchors, as the Control bar's buttons do.
Some((TASK_BAR_TO_CORNER, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "corner"})),
Some((TASK_BAR_TO_SMOOTH, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "smooth"})),
Some((c, p)) => crate::menus::invoke(app, c, p),
@@ -664,6 +677,11 @@ fn paint_or_object_chip(
}
}
/// Whether the bar's item that runs `cmd` shows its icon alone: the anchor convert buttons.
fn icon_only(cmd: &str) -> bool {
matches!(cmd, TASK_BAR_TO_CORNER | TASK_BAR_TO_SMOOTH)
}
/// The paints the Fill and Stroke chips show, and whether each is mixed ("?"): the proxies', or
/// while a Type tool edits type, the edited characters' fill, which a swatch picked then paints.
pub(crate) fn chip_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (vectorcraft_color::Paint, vectorcraft_color::Paint, bool, bool) {
@@ -714,8 +732,11 @@ const TASK_BAR_DUPLICATE_ARTBOARDS: &str = "taskBar.duplicateArtboards";
/// The Contextual Task Bar's Rearrange All button with the Artboard tool.
const TASK_BAR_REARRANGE_ARTBOARDS: &str = "taskBar.rearrangeArtboards";
/// The Contextual Task Bar's Corner button for direct-selected anchors.
/// The Contextual Task Bar's Convert Selected Anchor Points to Corner button, for direct-selected
/// anchors or a path selected whole with Direct Selection.
const TASK_BAR_TO_CORNER: &str = "taskBar.toCorner";
/// The Contextual Task Bar's Smooth button for direct-selected anchors.
/// The Contextual Task Bar's Convert Selected Anchor Points to Smooth button, for direct-selected
/// anchors or a path selected whole with Direct Selection. The bar's Smooth button runs
/// `object.path.smooth` instead.
const TASK_BAR_TO_SMOOTH: &str = "taskBar.toSmooth";
+42 -6
View File
@@ -10447,9 +10447,34 @@ fn the_artboard_tools_task_bar_buttons_act_on_the_selected_artboards() {
assert_eq!(boards(&app), before, "one undo step");
}
/// The names and places of the buttons the Contextual Task Bar draws, from the accessibility tree
/// (`ctx` has it enabled): an icon-only button is named by its hover text.
fn task_bar_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String, egui::Rect)> {
frame(app, ctx, vec![]);
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();
let Some(bar) = task_bar_rect(ctx) else { return vec![] };
let Some(update) = out.platform_output.accesskit_update.as_ref() else { return vec![] };
let mut v: Vec<(String, egui::Rect)> = update
.nodes
.iter()
.filter(|(_, n)| n.role() == egui::accesskit::Role::Button)
.filter_map(|(_, n)| {
let b = n.bounds()?;
let r = egui::Rect::from_min_max(Pos2::new(b.x0 as f32, b.y0 as f32), Pos2::new(b.x1 as f32, b.y1 as f32));
let label = n.label()?;
bar.contains(r.center()).then(|| (label.to_string(), r))
})
.collect();
v.sort_by(|a, b| a.1.left().total_cmp(&b.1.left()));
v
}
/// With anchors direct-selected, the Contextual Task Bar offers Remove Anchor Points, Connect,
/// Cut Path, Corner and Smooth. Smooth and Corner convert the selected anchor, Cut Path opens
/// the path there and Connect closes it again, as the Control bar's anchor buttons do.
/// Cut Path and the two convert buttons, which show their icons alone. Converting to smooth and to
/// corner changes the selected anchor, Cut Path opens the path there and Connect closes it again,
/// as the Control bar's anchor buttons do.
#[test]
fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -10458,10 +10483,21 @@ fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() {
app.select_tool("directSelection");
app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap();
let ctx = egui::Context::default();
ctx.enable_accesskit();
let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::<Vec<_>>();
assert_eq!(names(&mut app), ["Remove Anchor Points", "Connect", "Cut Path", "Corner", "Smooth", "Duplicate"]);
assert_eq!(names(&mut app), ["Remove Anchor Points", "Connect", "Cut Path", "Duplicate"]);
let buttons = task_bar_buttons(&mut app, &ctx).into_iter().map(|(t, _)| t).collect::<Vec<_>>();
let expected = [
"Remove Anchor Points",
"Connect",
"Cut Path",
"Convert Selected Anchor Points to Corner",
"Convert Selected Anchor Points to Smooth",
"Duplicate",
];
assert_eq!(buttons[..expected.len()], expected, "{buttons:?}");
let press = |app: &mut VectorcraftApp, label: &str| {
let labels = task_bar_labels(app, &ctx);
let labels = task_bar_buttons(app, &ctx);
let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center();
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
@@ -10471,11 +10507,11 @@ fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() {
let subpath =
|app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().path_data().unwrap().subpaths[0].clone();
let last_undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().unwrap().label.clone();
press(&mut app, "Smooth");
press(&mut app, "Convert Selected Anchor Points to Smooth");
let a = subpath(&app).anchors[1];
assert!(a.has_in() && a.has_out(), "smooth: {a:?}");
assert_eq!(last_undo(&app), "Convert Anchor Points");
press(&mut app, "Corner");
press(&mut app, "Convert Selected Anchor Points to Corner");
let a = subpath(&app).anchors[1];
assert!(!a.has_in() && !a.has_out(), "corner: {a:?}");
press(&mut app, "Cut Path");
+2
View File
@@ -5876,6 +5876,7 @@ digits Malayalam Malaiàlam
@msg no action set to save no hi ha cap conjunt d’accions per desar
@msg the action set file can't be read in full el fitxer de conjunt d’accions no es pot llegir sencer
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out S’ha carregat “{name}”: s’ha omès {n} pas|S’ha carregat “{name}”: s’han omès {n} passos
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` S’ha carregat “{name}”: {n} pas té una ordre desconeguda i s’ha conservat: “{action}”, pas {step}, `{command}`|S’ha carregat “{name}”: {n} passos tenen ordres desconegudes i s’han conservat; el primer: “{action}”, pas {step}, `{command}`
@msg not an action set file no és un fitxer de conjunt d’accions
@msg the action set file is larger than 8 MiB el fitxer de conjunt d’accions supera els 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: la referència superaria els {MAX_BYTES} bytes
@@ -6367,6 +6368,7 @@ digits Malayalam Malaiàlam
@msg truncated truncat
@msg Unembedded to {_1} S’ha desincrustat a {_1}
@msg unknown command `{0}` ordre desconeguda `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` el pas {step} de “{action}” executa l’ordre desconeguda `{command}`
@msg unknown command `{0}`; did you mean `{1}`? ordre desconeguda `{0}`; volies dir `{1}`?
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode control desconegut `{name}`: fillStroke, coloring, drawMode o screenMode
@msg Unknown dimension mode Mode de dimensió desconegut
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5055,6 +5055,7 @@ imageTrace Outline Obrys
@msg no action set to save žádná sada akcí k uložení
@msg the action set file can't be read in full soubor sady akcí nelze přečíst celý
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Načteno „{name}“: vynechán {n} krok|Načteno „{name}“: vynechány {n} kroky|Načteno „{name}“: vynecháno {n} kroků
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Načteno „{name}“: {n} krok má neznámý příkaz a byl ponechán: „{action}“, krok {step}, `{command}`|Načteno „{name}“: {n} kroky mají neznámé příkazy a byly ponechány; první: „{action}“, krok {step}, `{command}`|Načteno „{name}“: {n} kroků má neznámé příkazy a bylo ponecháno; první: „{action}“, krok {step}, `{command}`
@msg not an action set file není to soubor sady akcí
@msg the action set file is larger than 8 MiB soubor sady akcí je větší než 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: reference by byla větší než {MAX_BYTES} bajtů
@@ -5295,6 +5296,7 @@ imageTrace Outline Obrys
@msg tool shortcuts must be a single key (optionally with Shift) zkratky nástrojů musí být jediná klávesa (volitelně se Shift)
@msg truncated zkráceno
@msg unknown command `{0}` neznámý příkaz `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` krok {step} akce „{action}“ spouští neznámý příkaz `{command}`
@msg unknown command `{0}`; did you mean `{1}`? neznámý příkaz `{0}`; mysleli jste `{1}`?
@msg unknown effect `{id}` neznámý efekt `{id}`
@msg unknown format neznámý formát
Can't render this file because it is too large.
+130
View File
@@ -4669,6 +4669,7 @@ theme Appearance Erscheinungsbild
@msg no action set to save kein Aktionssatz zum Speichern
@msg the action set file can't be read in full die Aktionssatzdatei kann nicht vollständig gelesen werden
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}“ geladen: {n} Schritt wurde ausgelassen|„{name}“ geladen: {n} Schritte wurden ausgelassen
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` „{name}“ geladen: {n} Schritt hat einen unbekannten Befehl und wurde behalten: „{action}“, Schritt {step}, `{command}`|„{name}“ geladen: {n} Schritte haben unbekannte Befehle und wurden behalten; der erste: „{action}“, Schritt {step}, `{command}`
@msg not an action set file keine Aktionssatzdatei
@msg the action set file is larger than 8 MiB die Aktionssatzdatei ist größer als 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: die Referenz wäre größer als {MAX_BYTES} Byte
@@ -4910,6 +4911,7 @@ theme Appearance Erscheinungsbild
@msg tool shortcuts must be a single key (optionally with Shift) Tastaturbefehle für Werkzeuge müssen aus einer einzelnen Taste bestehen (optional mit Shift)
@msg truncated abgeschnitten
@msg unknown command `{0}` unbekannter Befehl `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` Schritt {step} von „{action}“ führt den unbekannten Befehl `{command}` aus
@msg unknown command `{0}`; did you mean `{1}`? unbekannter Befehl `{0}`; meinten Sie `{1}`?
@msg unknown effect `{id}` unbekannter Effekt `{id}`
@msg unknown format unbekanntes Format
@@ -8894,6 +8896,134 @@ breakText Glyphs Glyphen
@msg the paths are too many or too tangled to find where they cross die Pfade sind zu zahlreich oder zu verschlungen, um ihre Kreuzungen zu finden
@msg the rectangle or line reaches too many paths to check das Rechteck oder die Linie erreicht zu viele Pfade, um sie zu prüfen
@msg {name} must be a number {name} muss eine Zahl sein
# Messages no catalog translated yet: image export and color profiles, color books and swatch exchange files, 3D and Revolve, and script and file errors.
@msg 3D effects require finite coordinates within 100 million points 3D-Effekte erfordern endliche Koordinaten innerhalb von 100 Millionen Punkt
@msg 3D effects require one to ten lights 3D-Effekte erfordern ein bis zehn Lichter
@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP-Farbtiefe {_1}: 1, 4, 8, 16, 24 oder 32 Bit pro Pixel
@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer BMP-Kodierung fehlgeschlagen: {width} × {height} Pixel passen nicht zum Format oder zum Pixelpuffer
@msg GIF encoding failed: the palette image is malformed GIF-Kodierung fehlgeschlagen: Das Palettenbild ist fehlerhaft
@msg GIF encoding failed: {e} GIF-Kodierung fehlgeschlagen: {e}
@msg JPEG colour profile: {e} JPEG-Farbprofil: {e}
@msg JPEG encoding failed: pixel buffer doesn't match the image size JPEG-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße
@msg JPEG encoding failed: {e} JPEG-Kodierung fehlgeschlagen: {e}
@msg PNG encoding failed: pixel buffer doesn't match the image size PNG-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße
@msg PNG encoding failed: the palette image is malformed PNG-Kodierung fehlgeschlagen: Das Palettenbild ist fehlerhaft
@msg PNG encoding failed: {e} PNG-Kodierung fehlgeschlagen: {e}
@msg PNG encoding failed: {width} × {height} pixels is not a valid image size PNG-Kodierung fehlgeschlagen: {width} × {height} Pixel sind keine gültige Bildgröße
@msg PSD colour profile: {e} PSD-Farbprofil: {e}
@msg PSD encoding failed: the pixel buffer doesn't match the image size PSD-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße
@msg Revolve profile contains invalid coordinates Kreiseln: Das Profil enthält ungültige Koordinaten
@msg Revolve profile is too complex Kreiseln: Das Profil ist zu komplex
@msg Revolve projection is not finite Kreiseln: Die Projektion ist nicht endlich
@msg Revolve requires finite profile coordinates within 100 million points Kreiseln erfordert endliche Profilkoordinaten innerhalb von 100 Millionen Punkt
@msg Revolve surface is too complex; reduce Segments or simplify the profile Kreiseln: Die Oberfläche ist zu komplex; verringern Sie „Segmente“ oder vereinfachen Sie das Profil
@msg Revolve surface is too complex; simplify the profile Kreiseln: Die Oberfläche ist zu komplex; vereinfachen Sie das Profil
@msg Revolve visibility cannot split the projected boundary Kreiseln: Die Sichtbarkeitsberechnung kann die projizierte Begrenzung nicht teilen
@msg Revolve visibility creates too many fragments Kreiseln: Die Sichtbarkeitsberechnung erzeugt zu viele Fragmente
@msg Revolve visibility creates too many vector anchors Kreiseln: Die Sichtbarkeitsberechnung erzeugt zu viele Vektor-Ankerpunkte
@msg Revolve visibility exceeds its geometry work limit Kreiseln: Die Sichtbarkeitsberechnung überschreitet ihre Grenze für den Geometrieaufwand
@msg Revolve visibility exceeds its spatial index limit Kreiseln: Die Sichtbarkeitsberechnung überschreitet ihre Grenze für den räumlichen Index
@msg Revolve visibility has too many boundary crossings Kreiseln: Die Sichtbarkeitsberechnung hat zu viele Kreuzungen der Begrenzung
@msg Revolve visibility has too many input anchors Kreiseln: Die Sichtbarkeitsberechnung hat zu viele Eingabe-Ankerpunkte
@msg Revolve visibility requires convex cap sectors Kreiseln: Die Sichtbarkeitsberechnung erfordert konvexe Sektoren der Deckflächen
@msg Revolve visibility requires finite projected faces Kreiseln: Die Sichtbarkeitsberechnung erfordert endliche projizierte Flächen
@msg SVG options: {e} SVG-Optionen: {e}
@msg Save for Web settings: {e} Einstellungen für „Für Web speichern“: {e}
@msg TIFF colour profile: {e} TIFF-Farbprofil: {e}
@msg TIFF encoding failed: the pixel buffer doesn't match the image size TIFF-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße
@msg TIFF encoding failed: {e} TIFF-Kodierung fehlgeschlagen: {e}
@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa-Farbtiefe {_1}: 16, 24 oder 32 Bit pro Pixel
@msg Targa encoding failed: the pixel buffer doesn't match the image size Targa-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße
@msg The 3D projection is not finite Die 3D-Projektion ist nicht endlich
@msg The 3D shadow is too complex; turn Shadows off or simplify the art Der 3D-Schatten ist zu komplex; schalten Sie „Schatten“ aus oder vereinfachen Sie die Grafik
@msg The shape contains invalid coordinates Die Form enthält ungültige Koordinaten
@msg Too many Revolve faces for visibility evaluation Zu viele Kreiseln-Flächen für die Sichtbarkeitsberechnung
@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft liest höchstens {ASE_MAX_ENTRIES} Farben und Gruppen aus einer Farbfeld-Austauschdatei, und diese Bibliothek hat {entries}
@msg WebP encoding failed: {e} WebP-Kodierung fehlgeschlagen: {e}
@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why}
@msg `answers[{i}]` {e} `answers[{i}]` {e}
@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` ist smallThumb, mediumThumb, largeThumb, smallList oder largeList
@msg a line can't run from a point to itself eine Linie kann nicht von einem Punkt zu sich selbst verlaufen
@msg a line needs at least two points eine Linie braucht mindestens zwei Punkte
@msg a linked JavaScript file is outside the read roots eine verknüpfte JavaScript-Datei liegt außerhalb der Lesestammordner
@msg a number cannot be read eine Zahl kann nicht gelesen werden
@msg a swatch exchange block is too long ein Block der Farbfeld-Austauschdatei ist zu lang
@msg a swatch exchange name is too long ein Name in der Farbfeld-Austauschdatei ist zu lang
@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels ein Bild mit {sw} × {sh} Pixeln kann nicht auf {width} × {height} Pixel skaliert werden
@msg a {width} × {height} image is too large ein Bild mit {width} × {height} Pixeln ist zu groß
@msg a {width} × {height} image: {e} ein Bild mit {width} × {height} Pixeln: {e}
@msg an image could not be written: {e} ein Bild konnte nicht geschrieben werden: {e}
@msg an image that couldn't be read was left out ein Bild, das nicht gelesen werden konnte, wurde ausgelassen
@msg can't open “{name}”: {e} „{name}“ kann nicht geöffnet werden: {e}
@msg cannot align zip entries to {align} bytes die ZIP-Einträge können nicht auf {align} Byte ausgerichtet werden
@msg cannot open the automation {kind} root `{_1}`: {e} der Automatisierungs-Stammordner ({kind}) `{_1}` kann nicht geöffnet werden: {e}
@msg cannot read profile: {0} das Profil kann nicht gelesen werden: {0}
@msg color book version {version} isn't supported (only 1) die Farbbuchversion {version} wird nicht unterstützt (nur 1)
@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) Farbbücher im Farbraum {other} werden nicht unterstützt (nur RGB, CMYK und Lab)
@msg file name too long to zip: {_1} der Dateiname ist zu lang für ein ZIP-Archiv: {_1}
@msg file name too long to zip: {name} der Dateiname ist zu lang für ein ZIP-Archiv: {name}
@msg freeform line {i}: {e} Freiformlinie {i}: {e}
@msg freeform point {i}: {e} Freiformpunkt {i}: {e}
@msg give `{key}` or `{other}`, not both geben Sie `{key}` oder `{other}` an, nicht beides
@msg grayscale profiles can't be working spaces yet Graustufenprofile können noch keine Arbeitsfarbräume sein
@msg input contains NaN or infinite coordinates die Eingabe enthält NaN oder unendliche Koordinaten
@msg input path could not be closed der Eingabepfad konnte nicht geschlossen werden
@msg invalid ICC profile: {0} ungültiges ICC-Profil: {0}
@msg invalid scale {scale} ungültige Skalierung {scale}
@msg its localized names take more than {_1} KB seine lokalisierten Namen belegen mehr als {_1} KB
@msg its objects are nested too deep ({e}) seine Objekte sind zu tief verschachtelt ({e})
@msg its tag table lists the `{_1}` tag more than once seine Tag-Tabelle führt das Tag `{_1}` mehr als einmal auf
@msg not a graphic style library: {e} keine Grafikstil-Bibliothek: {e}
@msg not a swatch library (.vcswatches or .gpl) keine Farbfeldbibliothek (.vcswatches oder .gpl)
@msg not a swatch library (format `{_1}`) keine Farbfeldbibliothek (Format `{_1}`)
@msg not a swatch library: {e} keine Farbfeldbibliothek: {e}
@msg not an action set file ({e}) keine Aktionssatzdatei ({e})
@msg profile colour space {other} is not RGB, CMYK or Gray der Farbraum {other} des Profils ist nicht RGB, CMYK oder Graustufen
@msg regular expression too large regulärer Ausdruck zu groß
@msg script file is not UTF-8 text: {value} die Skriptdatei ist kein UTF-8-Text: {value}
@msg stop {i}: {e} Farbstopp {i}: {e}
@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) die Version {major}.{minor} der Farbfeld-Austauschdatei wird nicht unterstützt (nur 1.x)
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it die Grafik eines Diagrammdesigns in einer Diagrammreihe wird aus dem Design neu gezeichnet: Bearbeiten Sie das Design, um sie zu ändern
@msg the color book holds more than {ASE_MAX_ENTRIES} colors das Farbbuch enthält mehr als {ASE_MAX_ENTRIES} Farben
@msg the color book is cut short das Farbbuch ist unvollständig
@msg the color book's names take more than {_1} MB die Namen des Farbbuchs belegen mehr als {_1} MB
@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution das Bild hätte {w} × {h} Pixel; Rasterexporte sind auf {MAX_RASTER_SIDE} Pixel pro Seite und {_1} Megapixel begrenzt: Verringern Sie die Skalierung oder die Auflösung
@msg the optimised image can't be read back: {e} das optimierte Bild kann nicht zurückgelesen werden: {e}
@msg the pixel buffer doesn't match the image size der Pixelpuffer passt nicht zur Bildgröße
@msg the preview: {e} die Vorschau: {e}
@msg the request may not read the New Document profiles folder die Anfrage darf den Ordner der Profile für neue Dokumente nicht lesen
@msg the shapes are too degenerate to combine die Formen sind zu entartet, um sie zu kombinieren
@msg the shapes cross themselves and each other too often to combine die Formen überschneiden sich selbst und einander zu oft, um sie zu kombinieren
@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups die Farbfeld-Austauschdatei enthält mehr als {ASE_MAX_ENTRIES} Farben und Gruppen
@msg the swatch exchange file is cut short die Farbfeld-Austauschdatei ist unvollständig
@msg the {_1} plate can't be read: {e} der Farbauszug {_1} kann nicht gelesen werden: {e}
@msg this binary DXF file is damaged or cut short diese binäre DXF-Datei ist beschädigt oder unvollständig
@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that dies ist eine DWG-Zeichnung, die nicht gelesen werden kann: Speichern Sie sie in der CAD-App als DXF und öffnen Sie diese Datei
@msg too many classes zu viele Zeichenklassen
@msg too many groups zu viele Gruppen
@msg too many layers for PSD (at most 32767) zu viele Ebenen für PSD (höchstens 32767)
@msg too many swatch exchange blocks zu viele Blöcke in der Farbfeld-Austauschdatei
@msg unknown colour profile: {0} unbekanntes Farbprofil: {0}
@msg {_1} clashes with a built-in profile name {_1} kollidiert mit dem Namen eines integrierten Profils
@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} Pixel ist zu groß für GIF (höchstens 65535 Pixel pro Seite): Verringern Sie die Auflösung
@msg {_1}: no usable transform to/from sRGB {_1}: keine verwendbare Transformation von/nach sRGB
@msg {name} is not a CMYK profile {name} ist kein CMYK-Profil
@msg {name} is not an RGB profile {name} ist kein RGB-Profil
@msg {path}: could not be read {path}: konnte nicht gelesen werden
@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} Pixel ist zu groß für JPEG (höchstens 65535 Pixel pro Seite): Verringern Sie die Auflösung
@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} Pixel ist zu groß für Targa (höchstens 65535 Pixel pro Seite): Verringern Sie die Auflösung
@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} Pixel ist zu groß für PSD (höchstens {MAX_SIDE} Pixel pro Seite): Verringern Sie die Auflösung
# Messages other catalogs translate and German did not: Banner Hems and Grommets and recovery data.
@msg `{key}` must be from 0.01 to 1000 pt `{key}` muss zwischen 0,01 und 1000 pt liegen
@msg select the banner, or mark every artboard wählen Sie das Banner aus oder markieren Sie alle Zeichenflächen
@msg the banner has no width or height das Banner hat keine Breite oder Höhe
@msg the grommet inset is too large for the banner der Randabstand der Ösen ist zu groß für das Banner
@msg the hem is too deep for the banner der Saum ist zu tief für das Banner
@msg the spacing makes more than {MAX_GROMMETS} grommets der Abstand ergibt mehr als {MAX_GROMMETS} Ösen
@msg there is no banner to mark es gibt kein Banner zum Markieren
@msg turn on at least one side aktivieren Sie mindestens eine Seite
@msg with no hem and no grommets there is nothing to mark ohne Saum und ohne Ösen gibt es nichts zu markieren
@msg {saving} failed: {e} {saving} fehlgeschlagen: {e}
Banner Hems and Grommets… Bannersäume und Ösen…
Banner Hems and Grommets Bannersäume und Ösen
Hem Saum
Can't render this file because it is too large.
+2
View File
@@ -4701,6 +4701,7 @@ digits Malayalam Μαλαγιάλαμ
@msg no action set to save δεν υπάρχει σύνολο ενεργειών για αποθήκευση
@msg the action set file can't be read in full το αρχείο συνόλου ενεργειών δεν μπορεί να διαβαστεί ολόκληρο
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Φορτώθηκε το «{name}»: παραλείφθηκε {n} βήμα|Φορτώθηκε το «{name}»: παραλείφθηκαν {n} βήματα
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Φορτώθηκε το «{name}»: {n} βήμα έχει άγνωστη εντολή και διατηρήθηκε: «{action}», βήμα {step}, `{command}`|Φορτώθηκε το «{name}»: {n} βήματα έχουν άγνωστες εντολές και διατηρήθηκαν· το πρώτο: «{action}», βήμα {step}, `{command}`
@msg not an action set file δεν είναι αρχείο συνόλου ενεργειών
@msg the action set file is larger than 8 MiB το αρχείο συνόλου ενεργειών είναι μεγαλύτερο από 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: η αναφορά θα ήταν μεγαλύτερη από {MAX_BYTES} byte
@@ -4935,6 +4936,7 @@ digits Malayalam Μαλαγιάλαμ
@msg tool shortcuts must be a single key (optionally with Shift) οι συντομεύσεις εργαλείων πρέπει να περιλαμβάνουν ένα πλήκτρο (προαιρετικά με Shift)
@msg truncated περικομμένο
@msg unknown command `{0}` άγνωστη εντολή `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` το βήμα {step} της ενέργειας «{action}» εκτελεί την άγνωστη εντολή `{command}`
@msg unknown command `{0}`; did you mean `{1}`? άγνωστη εντολή `{0}`· μήπως εννοείτε `{1}`;
@msg unknown effect `{id}` άγνωστο εφέ `{id}`
@msg unknown format άγνωστη μορφή
Can't render this file because it is too large.
+2
View File
@@ -4745,6 +4745,7 @@ axis Both Ambos
@msg no action set to save no hay ningún conjunto de acciones que guardar
@msg the action set file can't be read in full el archivo de conjunto de acciones no se puede leer completo
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Se cargó “{name}”: se omitió {n} paso|Se cargó “{name}”: se omitieron {n} pasos
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Se cargó “{name}”: {n} paso tiene un comando desconocido y se conservó: “{action}”, paso {step}, `{command}`|Se cargó “{name}”: {n} pasos tienen comandos desconocidos y se conservaron; el primero: “{action}”, paso {step}, `{command}`
@msg not an action set file no es un archivo de conjunto de acciones
@msg the action set file is larger than 8 MiB el archivo de conjunto de acciones supera los 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: la referencia superaría los {MAX_BYTES} bytes
@@ -4986,6 +4987,7 @@ axis Both Ambos
@msg tool shortcuts must be a single key (optionally with Shift) los atajos de herramientas deben ser una sola tecla (opcionalmente con Shift)
@msg truncated truncado
@msg unknown command `{0}` comando desconocido `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` el paso {step} de “{action}” ejecuta el comando desconocido `{command}`
@msg unknown command `{0}`; did you mean `{1}`? comando desconocido `{0}`; ¿quiso decir `{1}`?
@msg unknown effect `{id}` efecto desconocido `{id}`
@msg unknown format formato desconocido
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4961,6 +4961,7 @@ imageTrace Outline Ääriviiva
@msg no action set to save ei tallennettavaa toimintosarjaa
@msg the action set file can't be read in full toimintosarjatiedostoa ei voi lukea kokonaan
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Ladattiin ”{name}”: jätettiin pois {n} vaihe|Ladattiin ”{name}”: jätettiin pois {n} vaihetta
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Ladattiin ”{name}”: {n} vaiheella on tuntematon komento, ja se säilytettiin: ”{action}”, vaihe {step}, `{command}`|Ladattiin ”{name}”: {n} vaiheella on tuntemattomia komentoja, ja ne säilytettiin; ensimmäinen: ”{action}”, vaihe {step}, `{command}`
@msg not an action set file ei ole toimintosarjatiedosto
@msg the action set file is larger than 8 MiB toimintosarjatiedosto on suurempi kuin 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: viite olisi suurempi kuin {MAX_BYTES} tavua
@@ -5194,6 +5195,7 @@ imageTrace Outline Ääriviiva
@msg tool shortcuts must be a single key (optionally with Shift) työkalujen pikanäppäinten on oltava yksittäinen näppäin (valinnaisesti Vaihto-näppäimen kanssa)
@msg truncated katkaistu
@msg unknown command `{0}` tuntematon komento `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` toiminnon ”{action}” vaihe {step} suorittaa tuntemattoman komennon `{command}`
@msg unknown command `{0}`; did you mean `{1}`? tuntematon komento `{0}`; tarkoititko `{1}`?
@msg unknown effect `{id}` tuntematon tehoste `{id}`
@msg unknown format tuntematon muoto
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4734,6 +4734,7 @@ axis Both Les deux
@msg no action set to save aucun jeu de scripts à enregistrer
@msg the action set file can't be read in full le fichier de jeu de scripts ne peut pas être lu en entier
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out « {name} » chargé : {n} étape a été ignorée|« {name} » chargé : {n} étapes ont été ignorées
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` « {name} » chargé : {n} étape a une commande inconnue et a été conservée : « {action} », étape {step}, `{command}`|« {name} » chargé : {n} étapes ont des commandes inconnues et ont été conservées ; la première : « {action} », étape {step}, `{command}`
@msg not an action set file ce n’est pas un fichier de jeu de scripts
@msg the action set file is larger than 8 MiB le fichier de jeu de scripts dépasse 8 Mio
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}` : la référence dépasserait {MAX_BYTES} octets
@@ -4975,6 +4976,7 @@ axis Both Les deux
@msg tool shortcuts must be a single key (optionally with Shift) les raccourcis d’outils doivent être une seule touche (éventuellement avec Maj)
@msg truncated tronqué
@msg unknown command `{0}` commande inconnue `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` l’étape {step} de « {action} » exécute la commande inconnue `{command}`
@msg unknown command `{0}`; did you mean `{1}`? commande inconnue `{0}` ; vouliez-vous dire `{1}` ?
@msg unknown effect `{id}` effet inconnu `{id}`
@msg unknown format format inconnu
Can't render this file because it is too large.
+2
View File
@@ -7520,6 +7520,7 @@ antiAlias Sharp शार्प
@msg not a Word document: its WordDocument stream doesn't start with a file information block वर्ड डॉक्यूमेंट नहीं: इसकी WordDocument धारा फ़ाइल सूचना ब्लॉक के साथ शुरू नहीं होती है
@msg the action set file can't be read in full एक्शन सेट फ़ाइल पूरी तरह नहीं पढ़ी जा सकती
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” लोड किया गया: {n} चरण छोड़ा गया|“{name}” लोड किया गया: {n} चरण छोड़े गए
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` “{name}” लोड किया गया: {n} चरण में अज्ञात आदेश है और उसे रखा गया: “{action}”, चरण {step}, `{command}`|“{name}” लोड किया गया: {n} चरणों में अज्ञात आदेश हैं और उन्हें रखा गया; पहला: “{action}”, चरण {step}, `{command}`
@msg not an action set file एक एक्शन सेट फ़ाइल नहीं
@msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल
@msg not an HP-GL file HP-GL फ़ाइल नहीं है
@@ -8410,6 +8411,7 @@ antiAlias Sharp शार्प
@msg unknown character style number field अज्ञात चरित्र स्टाइल संख्या क्षेत्र
@msg unknown CMYK profile `{name}` अज्ञात CMYK प्रोफ़ाइल `{name}`
@msg unknown command `{0}` अज्ञात आदेश `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` “{action}” का चरण {step} अज्ञात आदेश `{command}` चलाता है
@msg unknown command `{0}`; did you mean `{1}`? अज्ञात आदेश `{0}`; क्या आपको `{1}` का मतलब है?
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode अज्ञात नियंत्रण `{name}`: fillStroke, रंग, drawMode या screenMode
@msg Unknown dimension mode अज्ञात आयाम मोड
Can't render this file because it is too large.
+119
View File
@@ -5434,6 +5434,7 @@ imageTrace Outline Körvonal
@msg no action set to save nincs menthető műveletkészlet
@msg the action set file can't be read in full a műveletkészlet-fájl nem olvasható be teljesen
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” betöltve; kihagyott lépések: {n}
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` „{name}” betöltve; megtartott lépések ismeretlen paranccsal: {n}; az első: „{action}”, lépés: {step}, `{command}`
@msg not an action set file nem műveletkészlet-fájl
@msg the action set file is larger than 8 MiB a műveletkészlet-fájl nagyobb 8 MiB-nál
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: a referencia nagyobb lenne {MAX_BYTES} bájtnál
@@ -5807,6 +5808,7 @@ imageTrace Outline Körvonal
@msg truncated csonka
@msg Unembedded to {_1} Beágyazás megszüntetve, ide: {_1}
@msg unknown command `{0}` ismeretlen parancs: `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` „{action}”, lépés: {step}: ismeretlen parancsot futtat: `{command}`
@msg unknown command `{0}`; did you mean `{1}`? ismeretlen parancs: `{0}`; erre gondolt: `{1}`?
@msg unknown effect `{id}` ismeretlen effektus: `{id}`
@msg unknown format ismeretlen formátum
@@ -7483,3 +7485,120 @@ breakText Glyphs Glifák
Grommets Ringlik
Grommet Inset Ringlik távolsága a széltől
Diameter Átmérő
# Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import.
@msg 3D effects require finite coordinates within 100 million points A 3D effektusokhoz 100 millió ponton belüli véges koordináták kellenek
@msg 3D effects require one to ten lights A 3D effektusokhoz egy és tíz közötti számú fény kell
@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP-színmélység {_1}: 1, 4, 8, 16, 24 vagy 32 bit/pixel lehet
@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer A BMP-kódolás sikertelen: {width} × {height} pixel nem fér bele a formátumba vagy a pixelpufferbe
@msg GIF encoding failed: the palette image is malformed A GIF-kódolás sikertelen: a palettás kép hibás szerkezetű
@msg GIF encoding failed: {e} A GIF-kódolás sikertelen: {e}
@msg JPEG colour profile: {e} JPEG-színprofil: {e}
@msg JPEG encoding failed: pixel buffer doesn't match the image size A JPEG-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével
@msg JPEG encoding failed: {e} A JPEG-kódolás sikertelen: {e}
@msg PNG encoding failed: pixel buffer doesn't match the image size A PNG-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével
@msg PNG encoding failed: the palette image is malformed A PNG-kódolás sikertelen: a palettás kép hibás szerkezetű
@msg PNG encoding failed: {e} A PNG-kódolás sikertelen: {e}
@msg PNG encoding failed: {width} × {height} pixels is not a valid image size A PNG-kódolás sikertelen: {width} × {height} pixel nem érvényes képméret
@msg PSD colour profile: {e} PSD-színprofil: {e}
@msg PSD encoding failed: the pixel buffer doesn't match the image size A PSD-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével
@msg Revolve profile contains invalid coordinates Forgatás tengely körül: a profil érvénytelen koordinátákat tartalmaz
@msg Revolve profile is too complex Forgatás tengely körül: a profil túl összetett
@msg Revolve projection is not finite Forgatás tengely körül: a vetület nem véges
@msg Revolve requires finite profile coordinates within 100 million points Forgatás tengely körül: 100 millió ponton belüli véges profilkoordináták kellenek
@msg Revolve surface is too complex; reduce Segments or simplify the profile Forgatás tengely körül: a felület túl összetett; csökkentsd a Szakaszok értékét, vagy egyszerűsítsd a profilt
@msg Revolve surface is too complex; simplify the profile Forgatás tengely körül: a felület túl összetett; egyszerűsítsd a profilt
@msg Revolve visibility cannot split the projected boundary Forgatás tengely körül: a láthatóság számítása nem tudja felosztani a vetített határvonalat
@msg Revolve visibility creates too many fragments Forgatás tengely körül: a láthatóság számítása túl sok darabot hoz létre
@msg Revolve visibility creates too many vector anchors Forgatás tengely körül: a láthatóság számítása túl sok vektoros horgonypontot hoz létre
@msg Revolve visibility exceeds its geometry work limit Forgatás tengely körül: a láthatóság számítása túllépi a geometriai számítások korlátját
@msg Revolve visibility exceeds its spatial index limit Forgatás tengely körül: a láthatóság számítása túllépi a térbeli index korlátját
@msg Revolve visibility has too many boundary crossings Forgatás tengely körül: a láthatóság számításában túl sok a határvonal-keresztezés
@msg Revolve visibility has too many input anchors Forgatás tengely körül: a láthatóság számításában túl sok a bemeneti horgonypont
@msg Revolve visibility requires convex cap sectors Forgatás tengely körül: a láthatóság számításához konvex zárólap-szektorok kellenek
@msg Revolve visibility requires finite projected faces Forgatás tengely körül: a láthatóság számításához véges vetített lapok kellenek
@msg SVG options: {e} SVG-beállítások: {e}
@msg Save for Web settings: {e} Webes mentés beállításai: {e}
@msg TIFF colour profile: {e} TIFF-színprofil: {e}
@msg TIFF encoding failed: the pixel buffer doesn't match the image size A TIFF-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével
@msg TIFF encoding failed: {e} A TIFF-kódolás sikertelen: {e}
@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa-színmélység {_1}: 16, 24 vagy 32 bit/pixel lehet
@msg Targa encoding failed: the pixel buffer doesn't match the image size A Targa-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével
@msg The 3D projection is not finite A 3D vetület nem véges
@msg The 3D shadow is too complex; turn Shadows off or simplify the art A 3D árnyék túl összetett; kapcsold ki az Árnyékok beállítást, vagy egyszerűsítsd a rajzot
@msg The shape contains invalid coordinates Az alakzat érvénytelen koordinátákat tartalmaz
@msg Too many Revolve faces for visibility evaluation Túl sok lap a láthatóság kiértékeléséhez (Forgatás tengely körül)
@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} A VectorCraft legfeljebb {ASE_MAX_ENTRIES} színt és csoportot olvas be egy színmintacsere-fájlból, ebben a könyvtárban pedig ennyi van: {entries}
@msg WebP encoding failed: {e} A WebP-kódolás sikertelen: {e}
@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why}
@msg `index` must be a whole number `index`: egész szám kell
@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view`: smallThumb, mediumThumb, largeThumb, smallList vagy largeList lehet
@msg a line can't run from a point to itself egy vonal nem futhat egy pontból önmagába
@msg a line needs at least two points egy vonalhoz legalább két pont kell
@msg a number cannot be read egy szám nem olvasható be
@msg a swatch exchange block is too long egy színmintacsere-blokk túl hosszú
@msg a swatch exchange name is too long egy színmintacsere-név túl hosszú
@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels egy {sw} × {sh} méretű kép nem méretezhető át erre: {width} × {height} pixel
@msg a {width} × {height} image is too large egy {width} × {height} méretű kép túl nagy
@msg a {width} × {height} image: {e} egy {width} × {height} méretű kép: {e}
@msg an image could not be written: {e} egy képet nem sikerült kiírni: {e}
@msg an image that couldn't be read was left out kimaradt egy kép, amely nem volt beolvasható
@msg can't open “{name}”: {e} nem nyitható meg: „{name}”: {e}
@msg cannot align zip entries to {align} bytes a zip-bejegyzések nem igazíthatók {align} bájtos határra
@msg cannot open the automation {kind} root `{_1}`: {e} az automatizálás gyökérmappája nem nyitható meg ({kind}): `{_1}`: {e}
@msg cannot read profile: {0} a profil nem olvasható: {0}
@msg color book version {version} isn't supported (only 1) a színkönyv verziója nem támogatott: {version} (csak 1)
@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) a színkönyvek nem támogatottak ebben a színtérben: {other} (csak RGB, CMYK és Lab)
@msg file name too long to zip: {_1} a fájlnév túl hosszú a zip-tömörítéshez: {_1}
@msg file name too long to zip: {name} a fájlnév túl hosszú a zip-tömörítéshez: {name}
@msg freeform line {i}: {e} szabad formájú vonal {i}: {e}
@msg freeform point {i}: {e} szabad formájú pont {i}: {e}
@msg give `{key}` or `{other}`, not both `{key}` vagy `{other}`: csak az egyiket add meg, ne mindkettőt
@msg grayscale profiles can't be working spaces yet a szürkeárnyalatos profilok egyelőre nem lehetnek munkaszínterek
@msg input contains NaN or infinite coordinates a bemenet NaN vagy végtelen koordinátákat tartalmaz
@msg input path could not be closed a bemeneti útvonalat nem sikerült lezárni
@msg invalid ICC profile: {0} érvénytelen ICC-profil: {0}
@msg invalid scale {scale} érvénytelen méretarány: {scale}
@msg its localized names take more than {_1} KB a honosított nevei több mint {_1} KB helyet foglalnak
@msg its objects are nested too deep ({e}) az objektumai túl mélyen egymásba ágyazottak ({e})
@msg its tag table lists the `{_1}` tag more than once a címketáblája többször is felsorolja ezt a címkét: `{_1}`
@msg knockout groups with too many overlapping translucent elements are written as PDF knockout groups: their translucent elements knock out what they cover fully a túl sok egymást átfedő áttetsző elemet tartalmazó kiütő csoportok PDF-kiütőcsoportként íródnak: az áttetsző elemeik teljesen kiütik azt, amit takarnak
@msg not a graphic style library: {e} nem grafikaistílus-könyvtár: {e}
@msg not a swatch library (.vcswatches or .gpl) nem színminta-könyvtár (.vcswatches vagy .gpl)
@msg not a swatch library (format `{_1}`) nem színminta-könyvtár (formátum: `{_1}`)
@msg not a swatch library: {e} nem színminta-könyvtár: {e}
@msg not an action set file ({e}) nem műveletkészlet-fájl ({e})
@msg profile colour space {other} is not RGB, CMYK or Gray a profil színtere nem RGB, CMYK vagy szürkeárnyalatos: {other}
@msg regular expression too large a reguláris kifejezés túl nagy
@msg script file is not UTF-8 text: {value} a parancsfájl nem UTF-8 szöveg: {value}
@msg stop {i}: {e} színátmenet-pont {i}: {e}
@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) a színmintacsere-fájl verziója nem támogatott: {major}.{minor} (csak 1.x)
@msg the color book holds more than {ASE_MAX_ENTRIES} colors a színkönyv több mint {ASE_MAX_ENTRIES} színt tartalmaz
@msg the color book is cut short a színkönyv csonka
@msg the color book's names take more than {_1} MB a színkönyv nevei több mint {_1} MB helyet foglalnak
@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution a kép mérete {w} × {h} pixel lenne; a raszteres exportálás oldalanként legfeljebb {MAX_RASTER_SIDE} pixelt és összesen legfeljebb {_1} megapixelt enged: csökkentsd a méretarányt vagy a felbontást
@msg the optimised image can't be read back: {e} az optimalizált kép nem olvasható vissza: {e}
@msg the pixel buffer doesn't match the image size a pixelpuffer nem egyezik a kép méretével
@msg the preview: {e} az előnézeti kép: {e}
@msg the request may not read the New Document profiles folder a kérés nem olvashatja az Új dokumentum profiljainak mappáját
@msg the shapes are too degenerate to combine az alakzatok túlságosan elfajultak az egyesítéshez
@msg the shapes cross themselves and each other too often to combine az alakzatok túl sokszor keresztezik önmagukat és egymást az egyesítéshez
@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups a színmintacsere-fájl több mint {ASE_MAX_ENTRIES} színt és csoportot tartalmaz
@msg the swatch exchange file is cut short a színmintacsere-fájl csonka
@msg the {_1} plate can't be read: {e} a nyomólemez nem olvasható be ({_1}): {e}
@msg this binary DXF file is damaged or cut short ez a bináris DXF-fájl sérült vagy csonka
@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that ez DWG-rajz, amely nem olvasható: mentsd DXF-ként a CAD-alkalmazásból, és azt nyisd meg
@msg too many classes túl sok karakterosztály
@msg too many groups túl sok csoport
@msg too many layers for PSD (at most 32767) túl sok réteg a PSD formátumhoz (legfeljebb 32767)
@msg too many swatch exchange blocks túl sok színmintacsere-blokk
@msg unknown colour profile: {0} ismeretlen színprofil: {0}
@msg {_1} clashes with a built-in profile name {_1}: ütközik egy beépített profil nevével
@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} pixel túl nagy a GIF formátumhoz (oldalanként legfeljebb 65535 pixel): csökkentsd a felbontást
@msg {_1}: no usable transform to/from sRGB {_1}: nincs használható átalakítás az sRGB színtérbe vagy onnan
@msg {name} is not a CMYK profile {name}: nem CMYK-profil
@msg {name} is not an RGB profile {name}: nem RGB-profil
@msg {path}: could not be read {path}: nem sikerült beolvasni
@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} pixel túl nagy a JPEG formátumhoz (oldalanként legfeljebb 65535 pixel): csökkentsd a felbontást
@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} pixel túl nagy a Targa formátumhoz (oldalanként legfeljebb 65535 pixel): csökkentsd a felbontást
@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} pixel túl nagy a PSD formátumhoz (oldalanként legfeljebb {MAX_SIDE} pixel): csökkentsd a felbontást
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4797,6 +4797,7 @@ axis Both Entrambi
@msg no action set to save nessun set di azioni da salvare
@msg the action set file can't be read in full il file di set di azioni non può essere letto per intero
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Caricato “{name}”: {n} passaggio è stato omesso|Caricato “{name}”: {n} passaggi sono stati omessi
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Caricato “{name}”: {n} passaggio ha un comando sconosciuto ed è stato mantenuto: “{action}”, passaggio {step}, `{command}`|Caricato “{name}”: {n} passaggi hanno comandi sconosciuti e sono stati mantenuti; il primo: “{action}”, passaggio {step}, `{command}`
@msg not an action set file non è un file di set di azioni
@msg the action set file is larger than 8 MiB il file del set di azioni supera 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: il riferimento supererebbe {MAX_BYTES} byte
@@ -5038,6 +5039,7 @@ axis Both Entrambi
@msg tool shortcuts must be a single key (optionally with Shift) le scelte rapide degli strumenti devono essere un singolo tasto (eventualmente con Shift)
@msg truncated troncato
@msg unknown command `{0}` comando sconosciuto `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` il passaggio {step} di “{action}” esegue il comando sconosciuto `{command}`
@msg unknown command `{0}`; did you mean `{1}`? comando sconosciuto `{0}`; forse intendevate `{1}`?
@msg unknown effect `{id}` effetto sconosciuto `{id}`
@msg unknown format formato sconosciuto
Can't render this file because it is too large.
+2
View File
@@ -4715,6 +4715,7 @@ swatch Bright Violet ビビッドバイオレット
@msg objects can't be nested more than 32 deep オブジェクトは 32 階層より深く入れ子にできません
@msg the SVG's elements are nested more than {0} deep SVG の要素が {0} 階層より深く入れ子になっています
@msg unknown command `{0}` `{0}` は不明なコマンドです
@msg step {step} of “{action}” runs the unknown command `{command}` 「{action}」のステップ {step} は不明なコマンド `{command}` を実行します
@msg unknown command `{0}`; did you mean `{1}`? `{0}` は不明なコマンドです。`{1}` のことですか?
@msg `{what}` closes nothing `{what}` に対応する開始部分がありません
@msg `{what}` closes something it didn't open `{what}` が対応しない開始部分を閉じています
@@ -5064,6 +5065,7 @@ swatch Bright Violet ビビッドバイオレット
@msg no action set to save 保存するアクションセットがありません
@msg the action set file can't be read in full アクションセットファイルをすべて読み込めません
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 「{name}」を読み込みました。{n} 個のステップは省きました
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` 「{name}」を読み込みました。不明なコマンドのステップ {n} 個はそのまま残しました。最初は「{action}」のステップ {step}、`{command}` です
@msg not an action set file アクションセットファイルではありません
@msg the action set file is larger than 8 MiB アクションセットファイルが 8 MiB を超えています
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: リファレンスが {MAX_BYTES} バイトを超えます
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -4425,6 +4425,7 @@ font-technology Variable ცვლადი
@msg no action set to save შესანახი მოქმედებების ნაკრები არ არის
@msg the action set file can't be read in full მოქმედებების ნაკრების ფაილის სრულად წაკითხვა შეუძლებელია
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out ჩაიტვირთა „{name}“; გამოტოვებული ნაბიჯები: {n}
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` ჩაიტვირთა „{name}“; შენახული ნაბიჯები უცნობი ბრძანებით: {n}; პირველი: „{action}“, ნაბიჯი {step}, `{command}`
@msg not an action set file ეს არ არის მოქმედებების ნაკრების ფაილი
@msg the action set file is larger than 8 MiB მოქმედებების ნაკრების ფაილი 8 MiB-ზე დიდია
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: ცნობარი {MAX_BYTES} ბაიტზე დიდი იქნებოდა
@@ -4644,6 +4645,7 @@ font-technology Variable ცვლადი
@msg tool shortcuts must be a single key (optionally with Shift) ხელსაწყოს მალსახმობი ერთი კლავიში უნდა იყოს (სურვილისამებრ Shift-ით)
@msg truncated შეკვეცილია
@msg unknown command `{0}` უცნობი ბრძანება `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` „{action}“, ნაბიჯი {step}: უცნობი ბრძანება `{command}`
@msg unknown command `{0}`; did you mean `{1}`? უცნობი ბრძანება `{0}`; იქნებ `{1}` იგულისხმეთ?
@msg unknown effect `{id}` უცნობი ეფექტი `{id}`
@msg unknown format უცნობი ფორმატი
Can't render this file because it is too large.
+2
View File
@@ -4642,6 +4642,7 @@ theme Appearance 외관
@msg objects can't be nested more than 32 deep 개체는 32단계보다 깊게 중첩할 수 없습니다
@msg the SVG's elements are nested more than {0} deep SVG 요소가 {0}단계보다 깊게 중첩되어 있습니다
@msg unknown command `{0}` 알 수 없는 명령 `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` “{action}”의 단계 {step}에서 알 수 없는 명령 `{command}`을(를) 실행합니다
@msg unknown command `{0}`; did you mean `{1}`? 알 수 없는 명령 `{0}`: `{1}`을(를) 의미하셨습니까?
@msg `{what}` closes nothing `{what}`이(가) 닫는 항목이 없습니다
@msg `{what}` closes something it didn't open `{what}`이(가) 열지 않은 항목을 닫습니다
@@ -4991,6 +4992,7 @@ theme Appearance 외관
@msg no action set to save 저장할 액션 세트가 없습니다
@msg the action set file can't be read in full 액션 세트 파일을 전부 읽을 수 없습니다
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}”을(를) 불러왔습니다. 단계 {n}개를 제외했습니다
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` “{name}”을(를) 불러왔습니다. 알 수 없는 명령이 있는 단계 {n}개를 그대로 두었습니다. 첫 번째: “{action}”, 단계 {step}, `{command}`
@msg not an action set file 액션 세트 파일이 아닙니다
@msg the action set file is larger than 8 MiB 액션 세트 파일이 8 MiB보다 큽니다
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: 참조가 {MAX_BYTES}바이트보다 커집니다
Can't render this file because it is too large.
File diff suppressed because it is too large Load Diff
+2
View File
@@ -4879,6 +4879,7 @@ imageTrace Outline Kontur
@msg no action set to save brak zestawu operacji do zapisania
@msg the action set file can't be read in full nie można odczytać całego pliku zestawu operacji
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Wczytano „{name}”: pominięto {n} krok|Wczytano „{name}”: pominięto {n} kroki|Wczytano „{name}”: pominięto {n} kroków
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Wczytano „{name}”: {n} krok ma nieznane polecenie i został zachowany: „{action}”, krok {step}, `{command}`|Wczytano „{name}”: {n} kroki mają nieznane polecenia i zostały zachowane; pierwszy: „{action}”, krok {step}, `{command}`|Wczytano „{name}”: {n} kroków ma nieznane polecenia i zostało zachowanych; pierwszy: „{action}”, krok {step}, `{command}`
@msg not an action set file to nie jest plik zestawu operacji
@msg the action set file is larger than 8 MiB plik zestawu operacji jest większy niż 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: dokumentacja byłaby większa niż {MAX_BYTES} bajtów
@@ -5122,6 +5123,7 @@ imageTrace Outline Kontur
@msg tool shortcuts must be a single key (optionally with Shift) skróty narzędzi muszą być pojedynczym klawiszem (opcjonalnie z Shift)
@msg truncated obcięty
@msg unknown command `{0}` nieznane polecenie `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` krok {step} operacji „{action}” uruchamia nieznane polecenie `{command}`
@msg unknown command `{0}`; did you mean `{1}`? nieznane polecenie `{0}`; czy chodziło o `{1}`?
@msg unknown effect `{id}` nieznany efekt `{id}`
@msg unknown format nieznany format
Can't render this file because it is too large.
+2
View File
@@ -4199,6 +4199,7 @@ axis Both Ambos
@msg no action set to save nenhum conjunto de ações para salvar
@msg the action set file can't be read in full o arquivo de conjunto de ações não pode ser lido por inteiro
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” carregado: {n} etapa foi omitida|“{name}” carregado: {n} etapas foram omitidas
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` “{name}” carregado: {n} etapa tem um comando desconhecido e foi mantida: “{action}”, etapa {step}, `{command}`|“{name}” carregado: {n} etapas têm comandos desconhecidos e foram mantidas; a primeira: “{action}”, etapa {step}, `{command}`
@msg not an action set file não é um arquivo de conjunto de ações
@msg the action set file is larger than 8 MiB o arquivo de conjunto de ações tem mais de 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: a referência teria mais de {MAX_BYTES} bytes
@@ -4438,6 +4439,7 @@ axis Both Ambos
@msg tool shortcuts must be a single key (optionally with Shift) os atalhos de ferramenta devem ser uma única tecla (opcionalmente com Shift)
@msg truncated truncado
@msg unknown command `{0}` comando desconhecido `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` a etapa {step} de “{action}” executa o comando desconhecido `{command}`
@msg unknown command `{0}`; did you mean `{1}`? comando desconhecido `{0}`; você quis dizer `{1}`?
@msg unknown effect `{id}` efeito desconhecido `{id}`
@msg unknown format formato desconhecido
Can't render this file because it is too large.
+2
View File
@@ -4782,6 +4782,7 @@ axis Both Оба
@msg no action set to save нет набора действий для сохранения
@msg the action set file can't be read in full файл набора действий невозможно прочитать целиком
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Загружено «{name}»: пропущен {n} шаг|Загружено «{name}»: пропущено {n} шага|Загружено «{name}»: пропущено {n} шагов
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Загружено «{name}»: {n} шаг с неизвестной командой сохранён: «{action}», шаг {step}, `{command}`|Загружено «{name}»: {n} шага с неизвестными командами сохранены; первый: «{action}», шаг {step}, `{command}`|Загружено «{name}»: {n} шагов с неизвестными командами сохранены; первый: «{action}», шаг {step}, `{command}`
@msg not an action set file это не файл набора действий
@msg the action set file is larger than 8 MiB файл набора действий больше 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: справочник был бы больше {MAX_BYTES} байт
@@ -5023,6 +5024,7 @@ axis Both Оба
@msg tool shortcuts must be a single key (optionally with Shift) сочетания инструментов должны быть одной клавишей (необязательно с Shift)
@msg truncated усечено
@msg unknown command `{0}` неизвестная команда `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` шаг {step} действия «{action}» выполняет неизвестную команду `{command}`
@msg unknown command `{0}`; did you mean `{1}`? неизвестная команда `{0}`; возможно, имелась в виду `{1}`?
@msg unknown effect `{id}` неизвестный эффект `{id}`
@msg unknown format неизвестный формат
Can't render this file because it is too large.
+2
View File
@@ -4645,6 +4645,7 @@ axis Both Båda
@msg no action set to save ingen åtgärdsuppsättning att spara
@msg the action set file can't be read in full filen med åtgärdsuppsättning kan inte läsas helt
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Läste in ”{name}”: {n} steg utelämnades|Läste in ”{name}”: {n} steg utelämnades
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Läste in ”{name}”: {n} steg har ett okänt kommando och behölls: ”{action}”, steg {step}, `{command}`|Läste in ”{name}”: {n} steg har okända kommandon och behölls; det första: ”{action}”, steg {step}, `{command}`
@msg not an action set file inte en fil med åtgärdsuppsättning
@msg the action set file is larger than 8 MiB filen med åtgärdsuppsättningen är större än 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: referensen skulle bli större än {MAX_BYTES} byte
@@ -4879,6 +4880,7 @@ axis Both Båda
@msg tool shortcuts must be a single key (optionally with Shift) kortkommandon för verktyg måste vara en enda tangent (eventuellt med Shift)
@msg truncated avkortad
@msg unknown command `{0}` okänt kommando `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` steg {step} i ”{action}” kör det okända kommandot `{command}`
@msg unknown command `{0}`; did you mean `{1}`? okänt kommando `{0}`; menade du `{1}`?
@msg unknown effect `{id}` okänd effekt `{id}`
@msg unknown format okänt format
Can't render this file because it is too large.
+4
View File
@@ -2524,6 +2524,10 @@ const COUNTED_STATUS_MESSAGES: &[(&str, &str)] = &[
("Downloaded 1 file", "Downloaded 2 files"),
("Exported 1 file to /out", "Exported 2 files to /out"),
("Loaded “Tidy”: 1 step was left out", "Loaded “Tidy”: 2 steps were left out"),
(
"Loaded “Tidy”: 1 step has an unknown command and was kept: “Export”, step 4, `example.finish`",
"Loaded “Tidy”: 2 steps have unknown commands and were kept; the first: “Export”, step 4.2, `example.finish`",
),
];
/// The link, font, export and action set status messages name a count of 1 in the singular and other counts in
+2
View File
@@ -4479,6 +4479,7 @@ axis Both Her İkisi
@msg no action set to save kaydedilecek eylem seti yok
@msg the action set file can't be read in full eylem seti dosyası tamamen okunamıyor
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” yüklendi: {n} adım atlandı
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` “{name}” yüklendi: bilinmeyen komutlu {n} adım korundu; ilki: “{action}”, adım {step}, `{command}`
@msg not an action set file bir eylem seti dosyası değil
@msg the action set file is larger than 8 MiB eylem seti dosyası 8 MiB'den büyük
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: başvuru {MAX_BYTES} bayttan büyük olur
@@ -4702,6 +4703,7 @@ axis Both Her İkisi
@msg tool shortcuts must be a single key (optionally with Shift) araç kısayolları tek tuş olmalıdır (isteğe bağlı olarak Shift ile)
@msg truncated kesik
@msg unknown command `{0}` bilinmeyen komut `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` “{action}” eylemi, adım {step}: bilinmeyen `{command}` komutunu çalıştırıyor
@msg unknown command `{0}`; did you mean `{1}`? bilinmeyen komut `{0}`; `{1}` mi demek istediniz?
@msg unknown effect `{id}` bilinmeyen etki `{id}`
@msg unknown format bilinmeyen format
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -4781,6 +4781,7 @@ axis Both Обидві
@msg no action set to save немає набору дій для збереження
@msg the action set file can't be read in full файл набору дій неможливо прочитати повністю
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Завантажено «{name}»: пропущено {n} крок|Завантажено «{name}»: пропущено {n} кроки|Завантажено «{name}»: пропущено {n} кроків
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Завантажено «{name}»: {n} крок із невідомою командою збережено: «{action}», крок {step}, `{command}`|Завантажено «{name}»: {n} кроки з невідомими командами збережено; перший: «{action}», крок {step}, `{command}`|Завантажено «{name}»: {n} кроків із невідомими командами збережено; перший: «{action}», крок {step}, `{command}`
@msg not an action set file це не файл набору дій
@msg the action set file is larger than 8 MiB файл набору дій більший за 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: довідник був би більшим за {MAX_BYTES} байтів
@@ -5022,6 +5023,7 @@ axis Both Обидві
@msg tool shortcuts must be a single key (optionally with Shift) поєднання інструментів має бути однією клавішею (необов’язково з Shift)
@msg truncated усічено
@msg unknown command `{0}` невідома команда `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` крок {step} дії «{action}» виконує невідому команду `{command}`
@msg unknown command `{0}`; did you mean `{1}`? невідома команда `{0}`; можливо, ви мали на увазі `{1}`?
@msg unknown effect `{id}` невідомий ефект `{id}`
@msg unknown format невідомий формат
Can't render this file because it is too large.
+2
View File
@@ -5094,6 +5094,7 @@ theme Appearance Giao diện
@msg no action set to save không có bộ hành động nào để lưu
@msg the action set file can't be read in full không thể đọc hết tệp bộ hành động
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Đã tải “{name}”: bỏ qua {n} bước
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Đã tải “{name}”: giữ lại {n} bước có lệnh không xác định; bước đầu tiên: “{action}”, bước {step}, `{command}`
@msg not an action set file không phải tệp bộ hành động
@msg the action set file is larger than 8 MiB tệp bộ hành động lớn hơn 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: tài liệu tham khảo sẽ lớn hơn {MAX_BYTES} byte
@@ -5535,6 +5536,7 @@ theme Appearance Giao diện
@msg truncated bị cắt ngắn
@msg Unembedded to {_1} Đã bỏ nhúng vào {_1}
@msg unknown command `{0}` lệnh không xác định `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` bước {step} của “{action}” chạy lệnh không xác định `{command}`
@msg unknown command `{0}`; did you mean `{1}`? lệnh không xác định `{0}`; ý bạn là `{1}`?
@msg unknown effect `{id}` hiệu ứng không xác định `{id}`
@msg unknown format định dạng không xác định
Can't render this file because it contains an unexpected character in line 204 and column 5.
+2
View File
@@ -4409,6 +4409,7 @@ imageTrace Outline 轮廓
@msg objects can't be nested more than 32 deep 对象的嵌套不能超过 32 层
@msg the SVG's elements are nested more than {0} deep SVG 的元素嵌套超过 {0} 层
@msg unknown command `{0}` 未知命令 `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` “{action}”的步骤 {step} 运行未知命令 `{command}`
@msg unknown command `{0}`; did you mean `{1}`? 未知命令 `{0}`;您是否想输入 `{1}`?
@msg `{what}` closes nothing `{what}` 没有可结束的内容
@msg `{what}` closes something it didn't open `{what}` 结束了并非由它开始的内容
@@ -4744,6 +4745,7 @@ imageTrace Outline 轮廓
@msg no action set to save 没有要存储的动作组
@msg the action set file can't be read in full 无法完整读取动作组文件
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已载入“{name}”:已略过 {n} 个步骤
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` 已载入“{name}”:保留了 {n} 个含未知命令的步骤;第一个:“{action}”,步骤 {step},`{command}`
@msg not an action set file 不是动作组文件
@msg the action set file is larger than 8 MiB 动作组文件大于 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`:参考将大于 {MAX_BYTES} 字节
Can't render this file because it is too large.
+2
View File
@@ -2120,6 +2120,7 @@ file-filter Templates 範本
@msg objects can't be nested more than 32 deep 物件的巢狀層級不能超過 32 層
@msg the SVG's elements are nested more than {0} deep SVG 的元素巢狀超過 {0} 層
@msg unknown command `{0}` 未知的指令 `{0}`
@msg step {step} of “{action}” runs the unknown command `{command}` 「{action}」的步驟 {step} 執行未知的指令 `{command}`
@msg unknown command `{0}`; did you mean `{1}`? 未知的指令 `{0}`;您是指 `{1}` 嗎?
@msg `{what}` closes nothing `{what}` 沒有可結束的內容
@msg `{what}` closes something it didn't open `{what}` 結束了並非由它開始的內容
@@ -2458,6 +2459,7 @@ file-filter Templates 範本
@msg no action set to save 沒有要儲存的動作組
@msg the action set file can't be read in full 無法完整讀取動作組檔案
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已載入「{name}」:已略過 {n} 個步驟
@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` 已載入「{name}」:保留了 {n} 個含未知指令的步驟;第一個:「{action}」,步驟 {step},`{command}`
@msg not an action set file 不是動作組檔案
@msg the action set file is larger than 8 MiB 動作組檔案大於 8 MiB
@msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`:參考將大於 {MAX_BYTES} 位元組
Can't render this file because it is too large.
+1 -1
View File
@@ -1265,7 +1265,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.smoothAuto",
"Auto-Smooth",
"",
"{} the open Smooth slider (Object › Path › Smooth and the canvas context menu's Smooth… open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open",
"{} the open Smooth slider (Object › Path › Smooth, the canvas context menu's Smooth… and the Contextual Task Bar's Smooth open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open",
),
(
"ui.simplifyMoreOptions",
+184 -2
View File
@@ -85,6 +85,70 @@ impl Action {
})
.collect()
}
/// The steps whose command `known` does not hold, in order, as (step index, the index of the
/// command in a `command.batch` step, command id). A `command.batch` step's own commands are
/// checked one by one, as playback runs them ([`Action::included_commands`]); a command of one
/// with no command id has the id "". With `included_only`, excluded steps are passed over.
pub(crate) fn unknown_commands<'a>(
&'a self,
known: &'a KnownCommands,
included_only: bool,
) -> impl Iterator<Item = (usize, Option<usize>, &'a str)> + 'a {
self.steps.iter().enumerate().filter(move |(i, _)| !(included_only && self.excluded_steps.contains(i))).flat_map(
|(i, (command, params))| -> Vec<(usize, Option<usize>, &'a str)> {
match params.get("commands").and_then(Value::as_array) {
Some(steps) if command == "command.batch" => steps
.iter()
.enumerate()
.map(|(j, step)| (i, Some(j), step.get("command").and_then(Value::as_str).unwrap_or_default()))
.filter(|(_, _, id)| !known.has(id))
.collect(),
_ if known.has(command) => vec![],
_ => vec![(i, None, command.as_str())],
}
},
)
}
}
/// The command ids a step of an action can run now: the built-in engine commands, the commands the
/// installed plug-ins add, the View menu commands the app plays itself ([`crate::menus::plays_in_app`])
/// and stops ([`STOP_STEP`]).
pub(crate) struct KnownCommands(std::collections::HashSet<&'static str>);
impl KnownCommands {
/// The command ids known when it is called: a plug-in installed or removed later is not in it.
pub(crate) fn now() -> Self {
let engine = vectorcraft_engine::command_specs().iter().map(|c| c.id);
let plugins = vectorcraft_engine::cmd::plugin::command_specs().into_iter().map(|c| c.id);
Self(engine.chain(plugins).chain([STOP_STEP]).collect())
}
/// Whether a step can run command `id`.
pub(crate) fn has(&self, id: &str) -> bool {
self.0.contains(id) || crate::menus::plays_in_app(id)
}
}
/// The one-based place of a step that [`Action::unknown_commands`] gives: `3` for the third step,
/// `3.2` for the second command of a `command.batch` third step.
pub(crate) fn step_place(step: usize, in_batch: Option<usize>) -> String {
match in_batch {
Some(j) => format!("{}.{}", step + 1, j + 1),
None => (step + 1).to_string(),
}
}
/// `text` as a status message shows it: control characters become `�`, and text past 80
/// characters ends in `…`.
pub(crate) fn shown_text(text: &str) -> String {
const MAX: usize = 80;
let mut shown: String = text.chars().take(MAX).map(|c| if c.is_control() { '\u{fffd}' } else { c }).collect();
if text.chars().nth(MAX).is_some() {
shown.push('…');
}
shown
}
/// An action id no other action in this run has.
@@ -257,7 +321,10 @@ pub fn default_sets() -> Vec<ActionSet> {
/// Accelerated runs every included step at once as a single undoable batch → what `command.batch`
/// returns, {results}. Step by Step and Pause For run the first step now, each as its own undo step,
/// and leave the rest to [`tick_playback`] → {results: [the first step's result], pending: the steps
/// left}. While a paced playback is in progress, another action does not play.
/// left}. While a paced playback is in progress, another action does not play. An action with an
/// included step whose command is not known now ([`KnownCommands`]), a plug-in's command after the
/// plug-in was removed for one, does not play: the error names the first such step and its command,
/// and no step runs. An excluded step is not checked.
///
/// An action with an included stop ([`STOP_STEP`]) plays up to the stop and waits there at any speed
/// ([`continue_playback`]). At Accelerated, the steps between two stops are one undo step. → {results:
@@ -275,6 +342,13 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result<Value, S
if app.ui.action_playing.is_some() {
return Err("an action is already playing".into());
}
// A step whose command is not known now would fail only when playback reaches it, after the
// steps before it changed the document.
let known = KnownCommands::now();
if let Some((step, in_batch, command)) = a.unknown_commands(&known, true).next() {
let (step, action, command) = (step_place(step, in_batch), shown_text(&a.name), shown_text(command));
return Err(format!("step {step} of “{action}” runs the unknown command `{command}`"));
}
let options = app.ui.action_playback;
if has_stop(&commands) {
return play_with_stops(app, a.name, commands, options);
@@ -3907,6 +3981,112 @@ mod tests {
assert_eq!(set_names(&app), before, "no set added");
}
/// A step whose command is not known loads as it is, excluded or not and in a `command.batch`
/// step too, from `data` and from `path`: the answer lists each one and the status bar names the
/// first and how many there are. A set whose commands are all known loads with neither.
#[test]
fn load_actions_reports_steps_with_unknown_commands() {
let mut app = app_with_sets();
let files = with_files(&mut app, "/sets/unused.vcactions");
let batch = json!({"commands": [{"command": "object.move", "params": {"dx": 1, "dy": 0}}, {"command": "example.unknown.inner"}]});
let export = json!([
{"command": "object.move", "params": {"dx": 1, "dy": 0}},
{"command": "view.zoomIn", "params": {}},
{"command": STOP_STEP, "params": {"message": "", "allowContinue": false}},
{"command": "example.unknown.finish", "params": {"x": [1, 2]}},
{"command": "command.batch", "params": batch},
{"command": "example.unknown.off", "params": {}, "excluded": true},
]);
let tidy = json!({"name": "Tidy", "steps": [{"command": "object.move", "params": {"dx": 1, "dy": 0}}]});
let file = json!({"format": "vcactions", "version": 6, "name": "Print", "actions": [tidy, {"name": "Export", "steps": export}]}).to_string();
let want = json!([
{"action": "Export", "actionIndex": 1, "step": 4, "stepIndex": 3, "command": "example.unknown.finish"},
{"action": "Export", "actionIndex": 1, "step": 5, "stepIndex": 4, "batchStep": 2, "command": "example.unknown.inner"},
{"action": "Export", "actionIndex": 1, "step": 6, "stepIndex": 5, "command": "example.unknown.off"},
]);
let r = app.run(LOAD_SET, json!({"data": file})).unwrap();
assert_eq!(r, json!({"set": 3, "name": "Print", "actions": 2, "unknownCommands": want, "unknownCount": 3}));
assert_eq!(
app.ui.status,
"Loaded “Print”: 3 steps have unknown commands and were kept; the first: “Export”, step 4, `example.unknown.finish`"
);
let loaded = &app.ui.action_sets[3].actions[1];
assert_eq!(loaded.steps.len(), 6, "every step is kept");
assert_eq!(loaded.steps[3], ("example.unknown.finish".to_string(), json!({"x": [1, 2]})), "with its params");
assert_eq!(loaded.excluded_steps, BTreeSet::from([5]));
files.borrow_mut().insert("/sets/Print.vcactions".into(), file.into_bytes());
let r = app.run(LOAD_SET, json!({"path": "/sets/Print.vcactions"})).unwrap();
assert_eq!((r["name"].clone(), r["unknownCommands"].clone(), r["unknownCount"].clone()), (json!("Print 2"), want, json!(3)));
let saved = files::write_set(&app.ui.action_sets[4]);
assert!(saved.contains("example.unknown.finish") && saved.contains("example.unknown.inner"), "Save Actions… writes them back");
app.ui.status.clear();
let known = files::write_set(&app.ui.action_sets[1]);
assert_eq!(app.run(LOAD_SET, json!({"data": known})).unwrap(), json!({"set": 5, "name": "Mine 2", "actions": 2}));
assert_eq!(app.ui.status, "", "no message for a set whose commands are all known");
let empty = json!({"format": "vcactions", "version": 1, "name": "Empty", "actions": []}).to_string();
assert_eq!(app.run(LOAD_SET, json!({"data": empty})).unwrap(), json!({"set": 6, "name": "Empty", "actions": 0}));
assert_eq!(app.ui.status, "");
}
/// Load Actions… lists the first 1,000 steps with an unknown command and counts them all, and
/// the status bar shows an action name or command id with no control character and at most 80
/// characters, while the set and the answer keep them as they are. One step is named in the
/// singular.
#[test]
fn load_actions_bounds_what_it_reports_of_unknown_commands() {
let mut app = app_with_sets();
let steps = vec![json!({"command": "example.unknown"}); 1001];
let many = json!({"format": "vcactions", "version": 1, "name": "Many", "actions": [{"name": "A", "steps": steps}]});
let r = app.run(LOAD_SET, json!({"data": many.to_string()})).unwrap();
assert_eq!((r["unknownCommands"].as_array().map(Vec::len), r["unknownCount"].clone()), (Some(1000), json!(1001)));
assert_eq!(r["unknownCommands"][999], json!({"action": "A", "actionIndex": 0, "step": 1000, "stepIndex": 999, "command": "example.unknown"}));
let (name, id) = ("Odd\u{1b}[31m\nname", format!("example.\r\n{}", "x".repeat(200)));
let odd = json!({"format": "vcactions", "version": 1, "name": "Odd", "actions": [{"name": name, "steps": [{"command": id}]}]});
let r = app.run(LOAD_SET, json!({"data": odd.to_string()})).unwrap();
assert_eq!((r["unknownCommands"][0]["action"].clone(), r["unknownCommands"][0]["command"].clone()), (json!(name), json!(id)));
assert_eq!(app.ui.action_sets.last().unwrap().actions[0].steps[0].0, id, "the set keeps the id");
let shown_id = format!("example.\u{fffd}\u{fffd}{}…", "x".repeat(70));
assert_eq!(
app.ui.status,
format!("Loaded “Odd”: 1 step has an unknown command and was kept: “Odd\u{fffd}[31m\u{fffd}name”, step 1, `{shown_id}`")
);
}
/// An action with an included step whose command is not known does not play at any speed: no
/// step runs, and the error names the step, a `command.batch` step's command by its place in
/// the batch too. An excluded step with an unknown command does not stop it.
#[test]
fn an_action_with_an_unknown_command_does_not_play() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let id = vectorcraft_doc::NodeId(
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(),
);
app.run("select.set", json!({"ids": [id.0]})).unwrap();
let step = || ("object.move".to_string(), json!({"dx": 5, "dy": 0}));
let inner = json!({"commands": [{"command": "object.move", "params": {"dx": 5, "dy": 0}}, {"command": "example.unknown.inner"}]});
app.ui.action_sets = vec![ActionSet::new(
"Test",
vec![
Action::new("Move", vec![step(), ("example.unknown.finish".into(), json!({})), step()]),
Action::new("Batch", vec![step(), ("command.batch".into(), inner)]),
],
)];
let x = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
let before = x(&app);
for speed in [PlaybackSpeed::Accelerated, PlaybackSpeed::Step] {
app.ui.action_playback.speed = speed;
assert_eq!(play(&mut app, 0, 0).unwrap_err(), "step 2 of “Move” runs the unknown command `example.unknown.finish`");
assert_eq!(play(&mut app, 0, 1).unwrap_err(), "step 2.2 of “Batch” runs the unknown command `example.unknown.inner`");
assert_eq!(x(&app), before, "no step ran");
assert!(app.ui.action_playing.is_none());
}
app.ui.action_playback.speed = PlaybackSpeed::Accelerated;
app.ui.action_sets[0].actions[0].excluded_steps.insert(1);
play(&mut app, 0, 0).unwrap();
assert!((x(&app) - before - 10.0).abs() < 1e-9, "both included moves ran");
}
/// Where the host can read part of a file, Load Actions… reads one byte past 8 MiB: a larger
/// file is refused without reading all of it, and a smaller one loads.
#[test]
@@ -5086,7 +5266,9 @@ mod tests {
fn a_failing_step_ends_a_paced_playback_with_its_error() {
let (mut app, x) = app_with_three_moves();
app.run(PLAYBACK_OPTIONS, json!({"speed": "step"})).unwrap();
app.ui.action_sets[0].actions[0].steps[1] = ("no.such.command".into(), json!({}));
// A Select Object step that finds no object fails as it plays.
app.ui.action_sets[0].actions[0].steps[1] =
(SELECT_OBJECT_STEP.into(), json!({"note": "missing", "caseSensitive": false, "wholeWord": false}));
play(&mut app, 0, 0).unwrap();
assert_eq!(frame(&mut app, 0.0), None, "the frame after Play draws the first step");
assert_eq!(frame(&mut app, 0.0), None);
+48 -2
View File
@@ -41,7 +41,8 @@ pub(super) const MAX_SET_ACTIONS: usize = 1000;
/// The most steps [`read_set`] reads in one action.
pub(super) const MAX_ACTION_STEPS: usize = 10_000;
/// The most left-out steps that Load Actions… lists for an action set another app saved.
/// The most left-out steps that Load Actions… lists for an action set another app saved, and the
/// most steps with an unknown command it lists for an action set file.
const MAX_SKIPPED: usize = 1000;
/// An action set file: a header, the set's name and its actions.
@@ -488,6 +489,11 @@ fn load_aia(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
/// Actions… would write as a file Load Actions… refuses (larger than 8 MiB, as a file with less
/// white space or a name that gets a number can give) is refused too, and no set is added.
///
/// A step whose command is not known now ([`super::KnownCommands`]), excluded or not, and a command
/// of a `command.batch` step that is not, still loads as it is. The answer then also lists them,
/// and the status bar names the first and how many there are → `{…, unknownCommands: [{action,
/// actionIndex, step, stepIndex, batchStep?, command}] (at most 1,000), unknownCount: n}`.
///
/// An action set another app saved as text (`.aia`, read with `actionSet.read`) comes in with its
/// actions and the steps that stand for a VectorCraft command ([`step_name`], [`step_commands`]),
/// with no params; a step that is off in the file is excluded from playback. A set with no name is
@@ -537,7 +543,47 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
set.name = free_set_name(&app.ui.action_sets, &set.name);
// A number added to the name can make a file at the limit larger than it.
super::edit_keeps_set_loadable(app, None, &set)?;
let (unknown, unknown_count, first) = unknown_commands(&set);
let (name, actions) = (set.name.clone(), set.actions.len());
app.ui.action_sets.push(set);
Ok(json!({ "set": app.ui.action_sets.len() - 1, "name": name, "actions": actions }))
let mut answer = json!({ "set": app.ui.action_sets.len() - 1, "name": name, "actions": actions });
if let Some((action, step, command)) = first {
app.status(crate::i18n::msg_n(
unknown_count as u64,
"Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`",
"Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}`",
&[
("name", &super::shown_text(&name)),
("action", &super::shown_text(&action)),
("step", &step),
("command", &super::shown_text(&command)),
],
));
answer["unknownCommands"] = json!(unknown);
answer["unknownCount"] = json!(unknown_count);
}
Ok(answer)
}
/// The steps of `set` whose command is not known now ([`super::KnownCommands`]), excluded steps
/// included: the first [`MAX_SKIPPED`] as `[{action, actionIndex, step, stepIndex, batchStep?,
/// command}]` (`step` and `batchStep` count from 1, the indexes from 0), how many there are, and the
/// first one's action name, place ([`super::step_place`]) and command.
fn unknown_commands(set: &ActionSet) -> (Vec<Value>, usize, Option<(String, String, String)>) {
let known = super::KnownCommands::now();
let (mut listed, mut count, mut first) = (vec![], 0usize, None);
for (a, action) in set.actions.iter().enumerate() {
for (step, in_batch, command) in action.unknown_commands(&known, false) {
count += 1;
first.get_or_insert_with(|| (action.name.clone(), super::step_place(step, in_batch), command.to_string()));
if listed.len() < MAX_SKIPPED {
let mut entry = json!({ "action": action.name, "actionIndex": a, "step": step + 1, "stepIndex": step, "command": command });
if let Some(j) = in_batch {
entry["batchStep"] = json!(j + 1);
}
listed.push(entry);
}
}
}
(listed, count, first)
}
@@ -144,7 +144,10 @@ fn the_window_menu_and_the_symbols_panel_open_symbol_libraries() {
let under = |e: &crate::menus::MenuEntry| e.path == ["Window", "Symbol Libraries"];
let opened: Vec<_> =
entries.iter().filter(|e| under(e) && e.command.as_deref() == Some("window.symbolLibrary")).map(|e| e.params["library"].clone()).collect();
assert_eq!(opened, [json!("basic-shapes"), json!("ui-frames"), json!("map-markers"), json!("inventory-icons"), json!("skill-icons")]);
assert_eq!(
opened,
[json!("basic-shapes"), json!("ui-frames"), json!("map-markers"), json!("inventory-icons"), json!("skill-icons"), json!("weather-icons")]
);
let has = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled);
assert_eq!(has("window.symbolLibrary.other"), Some(true));
assert_eq!(has("ui.saveSymbolLibrary"), Some(false), "no symbols to save yet");
+42 -24
View File
@@ -125,24 +125,37 @@ fn write(app: &mut VectorcraftApp, store: &Arc<dyn RecoveryStore>, pick: impl Fn
for job in jobs {
let (uid, label, title) = (job.uid, format!("Saving recovery data for {}", job.title), job.title.clone());
let (done, at, saving) = (job.clone(), place.clone(), label.clone());
// The status line before the job starts: on the worker, the job sets one line ("<label>…")
// right after this; written right away, it sets none before `then` runs.
let (sets, started_line) = (app.ui.status_sets, format!("{label}…"));
let work = move |_: Writer| job.write();
// A failure is reported here, so the job's result is not an error: that would show as
// "<label> failed: <error>" over this line, or as the round's message when it ran here.
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| match (r, at) {
(Ok(r), _) => {
done.finish(&mut app.session);
app.status(format!("Saved recovery data for {title}"));
Ok(r)
}
(Err(e), Some(at)) => {
copy_failed(app, &title, &at, &e);
Ok(Value::Null)
}
// A store the host installed has no folder to name: the job's own line.
(Err(e), None) => {
app.status(format!("{saving} failed: {e}"));
log::warn!("vectorcraft: {}", app.ui.status);
Ok(Value::Null)
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
// The result goes to the status bar only while it still shows this job's own line: a
// message set since then (another job's, a restore's with its notes) stays.
let show = app.ui.status_sets == sets || (app.ui.status_sets == sets.wrapping_add(1) && app.ui.status == started_line);
match (r, at) {
(Ok(r), _) => {
done.finish(&mut app.session);
if show {
app.status(format!("Saved recovery data for {title}"));
}
Ok(r)
}
(Err(e), Some(at)) => {
copy_failed(app, &title, &at, &e, show);
Ok(Value::Null)
}
// A store the host installed has no folder to name: the job's own line.
(Err(e), None) => {
let line = format!("{saving} failed: {e}");
log::warn!("vectorcraft: {line}");
if show {
app.status(line);
}
Ok(Value::Null)
}
}
};
// Its own store, not the file writer: this only decides whether it runs on the worker.
@@ -173,18 +186,23 @@ fn failed(app: &mut VectorcraftApp, place: Option<&(String, bool)>, e: &str) {
log::warn!("vectorcraft: {}", app.ui.status);
}
/// [`failed`] for the copy of document `title` that couldn't be written to folder `place`.
fn copy_failed(app: &mut VectorcraftApp, title: &str, place: &(String, bool), e: &str) {
match place {
(folder, true) => app.status(format!(
/// [`failed`] for the copy of document `title` that couldn't be written to folder `place`, shown
/// in the status bar only when `show` holds. The log always has it, and the folder is recorded
/// in [`Timer::named`] either way.
fn copy_failed(app: &mut VectorcraftApp, title: &str, place: &(String, bool), e: &str, show: bool) {
let line = match place {
(folder, true) => format!(
"Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)"
)),
(folder, false) => app.status(format!(
),
(folder, false) => format!(
"Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)"
)),
}
),
};
named(app, &place.0);
log::warn!("vectorcraft: {}", app.ui.status);
log::warn!("vectorcraft: {line}");
if show {
app.status(line);
}
}
/// Record that the status text named recovery folder `folder` ([`Timer::named`]).

Some files were not shown because too many files have changed in this diff Show More