Merge main into fix/paste-copied-file-size-limit

This commit is contained in:
dotCooCoo
2026-11-14 05:39:15 -08:00
99 changed files with 6275 additions and 441 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));
}
}
+3 -3
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply as Filter",
["Object", "Plug-ins", "Color Effects"],
None,
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step (none when nothing changes); their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}",
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step (none when nothing changes); their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed}",
has_selection,
filter
),
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Ink Fixes",
["Object", "Plug-ins", "Color Effects"],
None,
"{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}",
"{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}",
has_doc,
print_fixes::run
),
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Nearest Swatch",
["Object", "Plug-ins", "Color Effects"],
None,
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step (none when nothing changes): a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step (none when nothing changes): a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
has_selection,
nearest::apply
),
+11 -4
View File
@@ -529,9 +529,11 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
/// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke
/// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters).
/// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on
/// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and
/// when `f` leaves every target as it was, the document and its undo history stay as they are.
/// What the edit changes on the topmost visible fill and stroke of graph series marks, and on the
/// rows and characters of graph labels, is stored on their graphs in the same step
/// ([`capture_series_changes`], [`super::graph::capture_label_changes`]). Nothing targeted is not
/// an edit, and when `f` leaves every target as it was, the document and its undo history stay as
/// they are.
pub(crate) fn edit_items(
s: &mut Session,
ids: &[NodeId],
@@ -541,7 +543,12 @@ pub(crate) fn edit_items(
fill: bool,
f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
) -> Result<()> {
edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited)))
edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| {
let series = capture_series_changes(before, d, edited);
// The rows `f` edited are kept by `edit_items_then`.
let labels = super::graph::capture_label_changes(before, d, edited, false);
Ok(series || labels)
})
}
/// [`edit_items`], then `after` on the document as it was before the edit, the edited document and
+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,
});
}
+6 -4
View File
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Invert Colors",
["Edit", "Edit Colors"],
None,
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
has_selection,
|s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert)
),
@@ -513,14 +513,16 @@ pub(crate) fn edit_if_changed<T>(s: &mut Session, label: &str, f: impl FnOnce(&D
}
/// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed
/// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn
/// again.
/// on the graph series marks and graph labels among `roots` and inside them, so the marks and every label of a kind keep
/// their new paint when the graph is drawn again.
pub(crate) fn keeping_series_paint(d: &mut Document, roots: &[NodeId], recolor: impl FnOnce(&mut Document) -> Result<usize>) -> Result<usize> {
let marks = super::graph::series_marks_in(d, roots);
let before = (!marks.is_empty()).then(|| d.clone());
let labels = super::graph::labels_in(d, roots);
let before = (!marks.is_empty() || !labels.is_empty()).then(|| d.clone());
let n = recolor(d)?;
if let Some(before) = before.filter(|_| n > 0) {
super::appearance::capture_series_changes(&before, d, &marks);
super::graph::capture_label_changes(&before, d, &labels, true);
}
Ok(n)
}
@@ -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]
+1 -1
View File
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Freeform Point",
[],
None,
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {index}",
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind; the points on a label's characters are not stored → {index}",
has_doc,
add_point
),
+2 -2
View File
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient",
["Window", "Gradient"],
None,
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind",
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without its placement, fitted to each label",
has_doc,
edit_gradient
),
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient Vector",
[],
None,
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label",
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without the placement this command sets, fitted to each label",
has_doc,
set_gradient_geom
),
+4 -4
View File
@@ -45,10 +45,10 @@ use generate::generate;
#[cfg(test)]
use series::default_series_color;
pub(crate) use series::{
LabelTextChange, capture_label_anti_alias, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, capture_label_styles,
capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, is_design_art,
label_object_opacity_differs, paint_targets, series_group_marks, series_group_marks_where, series_marks_in, series_object_opacity_differs,
shown_paint,
LabelTextChange, capture_label_anti_alias, capture_label_changes, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows,
capture_label_styles, capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, is_design_art,
label_object_opacity_differs, labels_in, paint_targets, series_group_marks, series_group_marks_where, series_marks_in,
series_object_opacity_differs, shown_paint,
};
#[cfg(test)]
+2 -3
View File
@@ -6,7 +6,7 @@ use super::labels::{
recolour_text, runs, shadow_of,
};
use super::markers::{MARKER_BUDGET, marker_cost, marker_designs};
use super::series::{series_marks, with_series_gradients, with_series_opacity};
use super::series::{picks_legend_fill, series_marks, with_series_gradients, with_series_opacity};
use super::{
Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes,
};
@@ -397,8 +397,7 @@ impl<'a> Plot<'a> {
let at = ring.centre + dir(mid) * (ring.hole + (ring.rad - ring.hole) * 0.62);
let t = self.b.label(Role::Legend, Point::new(at.x, at.y + self.ls * 0.35), &label, Justify::Center, true);
// White on a dark wedge, unless the legend labels were given a colour of their own.
let own_colour = self.g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK));
self.wedge_labels.push(if dark && !own_colour { recolour_text(&t, Color::WHITE) } else { t });
self.wedge_labels.push(if dark && picks_legend_fill(self.g) { recolour_text(&t, Color::WHITE) } else { t });
}
/// The pie's category name.
+155 -10
View File
@@ -626,20 +626,27 @@ pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], metho
pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool {
let mut changed = false;
for (graph, role) in label_kinds(doc, ids) {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
let mut style = label_style(&spec.label_styles, role);
change(&mut style);
let slot = match role {
Role::Value => &mut spec.label_styles.values,
Role::Category => &mut spec.label_styles.categories,
Role::Legend => &mut spec.label_styles.legend,
};
changed |= slot.as_ref() != Some(&style);
*slot = Some(style);
changed |= store_label_style(doc, graph, role, change);
}
changed
}
/// Apply `change` to the style graph `graph` keeps for its labels of kind `role` (or the default one). Returns whether
/// the kept style changed.
fn store_label_style(doc: &mut Document, graph: NodeId, role: Role, change: &dyn Fn(&mut CharStyle)) -> bool {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { return false };
let mut style = label_style(&spec.label_styles, role);
change(&mut style);
let slot = match role {
Role::Value => &mut spec.label_styles.values,
Role::Category => &mut spec.label_styles.categories,
Role::Legend => &mut spec.label_styles.legend,
};
let changed = slot.as_ref() != Some(&style);
*slot = Some(style);
changed
}
/// Keep a paragraph change made to all of the text of graph labels `ids` (Paragraph, on the whole graph or on labels
/// picked with Group Selection) for every label of their kind, as [`capture_label_styles`] does for character
/// formats. Returns whether kept attributes changed, or the error `change` returns for the kept attributes.
@@ -807,6 +814,144 @@ pub(crate) fn capture_label_text(
Ok(changed)
}
/// The character fill and stroke attributes a kind of graph label keeps when a recolor, or a stroke or gradient edit
/// aimed at the characters, changes them on its labels ([`capture_label_changes`]). Each entry copies one attribute
/// from the first style to the second and returns whether they differed.
const RUN_PAINT: [fn(&CharStyle, &mut CharStyle) -> bool; 8] = [
|a, b| keep(&a.fill, &mut b.fill),
|a, b| keep(&a.stroke, &mut b.stroke),
|a, b| keep(&a.stroke_width, &mut b.stroke_width),
|a, b| keep(&a.stroke_cap, &mut b.stroke_cap),
|a, b| keep(&a.stroke_join, &mut b.stroke_join),
|a, b| keep(&a.stroke_miter_limit, &mut b.stroke_miter_limit),
|a, b| keep(&a.stroke_dash, &mut b.stroke_dash),
|a, b| keep(&a.stroke_align, &mut b.stroke_align),
];
/// Copy `from` to `to`. Returns whether they differed.
fn keep<T: Clone + PartialEq>(from: &T, to: &mut T) -> bool {
let differs = from != to;
if differs {
to.clone_from(from);
}
differs
}
/// The [`RUN_PAINT`] attributes an edit changed on the characters of graph label `now` (`was`: the label before the
/// edit), as the label's style and the indexes of the changed entries. An attribute counts only when every character
/// of the label shows it alike, and the fill only when `fill` is set. A placed gradient is kept without its placement,
/// so it fits each label it lands on. The stroke weight and dashes are kept as the label shows them: the graph draws its
/// labels again without the label's own scale.
fn run_paint_change(was: &Node, now: &Node, fill: bool) -> Option<(CharStyle, Vec<usize>)> {
let (NodeKind::Text(was), NodeKind::Text(now)) = (&was.kind, &now.kind) else { return None };
let first_now = &now.runs.first()?.style;
// Changed: some character showed something else before the edit, whichever run it is in.
let changed: Vec<usize> = RUN_PAINT
.iter()
.enumerate()
.filter(|(i, copy)| {
(fill || *i != 0)
&& now.runs.iter().all(|r| !copy(first_now, &mut r.style.clone()))
&& was.runs.iter().any(|r| copy(first_now, &mut r.style.clone()))
})
.map(|(i, _)| i)
.collect();
if changed.is_empty() {
return None;
}
let unplaced = crate::cmd::gradient::unplaced;
let mut style = first_now.clone();
(style.fill, style.stroke) = (unplaced(&style.fill), unplaced(&style.stroke));
// Character strokes are drawn in text space, scaled by the type's transform.
if let Some(k) = vectorcraft_doc::Scaling::factor(now.xf).filter(|k| k.is_finite() && *k > 1e-12) {
let mut st = style.stroke_layer();
crate::inspect::scale_layer(&mut st, k);
style.set_stroke_layer(&st);
}
Some((style, changed))
}
/// Does graph `g` pick the fill of its legend labels itself, white on a dark wedge and black on the others: a pie with
/// Legends in Wedges whose legend labels keep no fill of their own?
pub(super) fn picks_legend_fill(g: &GraphSpec) -> bool {
g.kind == GraphKind::Pie
&& g.legend
&& g.pie_legend_in_wedges
&& !g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK))
}
/// The graph labels among `roots` and inside them, for an edit that reaches graph labels without naming them.
pub(crate) fn labels_in(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let mut texts = Vec::new();
for id in roots {
let Some(root) = doc.node(*id) else { continue };
root.walk(&mut |n| {
if matches!(n.kind, NodeKind::Text(_)) {
texts.push(n.id);
}
});
}
// Graph labels are type: without type among `roots`, the graph's labels are not looked up.
if texts.is_empty() {
return texts;
}
let labels = graph_labels(doc);
texts.retain(|id| labels.contains_key(id));
// Nested roots (a graph and one of its labels) reach a label twice.
texts.sort_unstable();
texts.dedup();
texts
}
/// Keep for every label of their kind what an edit changed on graph labels `ids` (`before`: the document before the
/// edit): with `rows`, the object rows ([`capture_label_rows`]) of the labels whose rows changed, and the character fill
/// and stroke attributes ([`RUN_PAINT`]) that changed on all of a label's characters, on the kind's kept style. The
/// frontmost label of a kind sets what that kind keeps, whatever order `ids` are in. On a graph that colors its legend
/// labels itself ([`picks_legend_fill`]), their fill is kept only when the legend labels among `ids` end up with one
/// fill. Called inside the same edit, so they undo together. Returns whether what a graph keeps changed.
pub(crate) fn capture_label_changes(before: &Document, doc: &mut Document, ids: &[NodeId], rows: bool) -> bool {
if !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) {
return false;
}
let labels = graph_labels(doc);
let mut picked: Vec<NodeId> = ids.iter().copied().filter(|id| labels.contains_key(id)).collect();
// A later label's capture replaces an earlier one's: take them in paint order, each once.
picked.sort_by_cached_key(|id| doc.index_path(*id));
picked.dedup();
let picks_fill = |graph: NodeId| doc.node(graph).and_then(|n| n.extras().graph.as_deref()).is_some_and(picks_legend_fill);
// On a pie that colors its legend labels black or white by their wedge, a recolor of the whole pie flips them both
// ways: their fill is kept only when every legend label the edit reached ends up with the same one.
let mut wedge_fills: HashMap<NodeId, Vec<Paint>> = HashMap::new();
for id in &picked {
let (Some(&(graph, Role::Legend)), Some(NodeKind::Text(t))) = (labels.get(id), doc.node(*id).map(|n| &n.kind)) else { continue };
if picks_fill(graph) {
wedge_fills.entry(graph).or_default().extend(t.runs.iter().map(|r| r.style.fill.clone()));
}
}
let one_fill = |graph: NodeId| wedge_fills.get(&graph).is_none_or(|f| f.windows(2).all(|w| w[0] == w[1]));
let mut changed_rows = Vec::new();
let mut characters = Vec::new();
for (id, kind) in picked.iter().filter_map(|id| Some((*id, *labels.get(id)?))) {
let (Some(was), Some(now)) = (before.node(id), doc.node(id)) else { continue };
if rows && label_rows(was) != label_rows(now) {
changed_rows.push(id);
}
let fill = kind.1 != Role::Legend || one_fill(kind.0);
characters.extend(run_paint_change(was, now, fill).map(|change| (kind, change)));
}
let mut changed = capture_label_rows(doc, &changed_rows);
for ((graph, role), (style, entries)) in &characters {
changed |= store_label_style(doc, *graph, *role, &|st| {
for i in entries {
if let Some(copy) = RUN_PAINT.get(*i) {
copy(style, st);
}
}
});
}
changed
}
/// The graphs and kinds of label among `ids`, each once.
fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> {
if ids.is_empty() {
+241
View File
@@ -6074,6 +6074,247 @@ fn a_graphic_style_on_a_label_is_kept_for_its_kind() {
assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#0080ff"), "{looks:?}");
}
/// The character style of the first run of label `text` in group `grp`.
fn label_run_style(s: &Session, id: NodeId, grp: &str, text: &str) -> CharStyle {
let NodeKind::Text(t) = label_node(s, id, grp, text).kind else { panic!("{text} is not type") };
t.runs[0].style.clone()
}
/// A recolor of the whole graph (Invert or Complement on the proxy, an Edit Colors command, a color filter or Recolor
/// Artwork) is kept for the characters and the rows of every kind of label when the graph is drawn again, and undo takes
/// it back with its edit.
#[test]
fn recoloring_a_graph_keeps_its_labels_paint_for_every_kind() {
let cases = [
("paint.invert", json!({})),
("paint.complement", json!({})),
("edit.colors.invert", json!({})),
("edit.colors.toGrayscale", json!({})),
("addon.color.filter", json!({"effect": "addon.color.invert"})),
("recolor.apply", json!({"map": {"#3366cc": "#00ff00", "#ff8000": "#8000ff"}})),
];
every_case(cases, |(cmd, params)| {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
// Characters of every label in a color that every case changes, and an orange fill row on the category labels.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("paint.setFill", &json!({"color": "#3366cc"})).unwrap();
let c = text_id(&s, id, "Axes", "Q2");
s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff8000"})).unwrap();
let was = spec(&s, id).label_styles;
let mut p = params.clone();
if *cmd == "recolor.apply" {
// Recolor Artwork works on the selection.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
} else {
p["ids"] = json!([id.0]);
}
s.execute(cmd, &p).unwrap();
let hex = |p: &Paint| p.color().map(|c| c.to_hex()).unwrap_or_default();
let characters = hex(&label_run_style(&s, id, "Axes", "4").fill);
assert_ne!(characters, "#3366cc", "{cmd} recolors the characters");
let row = label_node(&s, id, "Axes", "Q2").appearance.items[0].paint().clone();
assert_ne!(hex(&row), "#ff8000", "{cmd} recolors the row");
let looks = label_looks(&mut s, id);
for t in ["0", "Q1", "2024"] {
assert!(looks.iter().any(|(x, _, fill)| x == t && *fill == characters), "{cmd}: {t}: {looks:?}");
}
let rows = redrawn_label_rows(&mut s, id);
assert_eq!(rows_of(&rows, "Q1").0.first().map(|r| r.1.clone()), Some(hex(&row)), "{cmd}: {rows:?}");
// Undo takes back the recolor and what the graph keeps (the two redraws are undone first).
for _ in 0..3 {
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(spec(&s, id).label_styles, was, "{cmd}");
});
}
/// A recolor of one label changes what the graph keeps for that kind of label only.
#[test]
fn recoloring_one_label_keeps_its_paint_for_its_kind_only() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let v = text_id(&s, id, "Axes", "4");
s.execute("edit.colors.invert", &json!({"ids": [v.0]})).unwrap();
let looks = label_looks(&mut s, id);
assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#ffffff"), "{looks:?}");
assert!(looks.iter().any(|(t, _, fill)| t == "Q1" && fill == "#000000"), "{looks:?}");
assert!(looks.iter().any(|(t, _, fill)| t == "2024" && fill == "#000000"), "{looks:?}");
let g = spec(&s, id).label_styles;
assert!(g.categories.is_none() && g.legend.is_none(), "{g:?}");
}
/// A recolor that leaves a label's first characters as they were and gives the rest the same fill is kept for its kind.
#[test]
fn a_recolor_that_evens_out_a_labels_characters_is_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let q2 = text_id(&s, id, "Axes", "Q2");
s.select_tool("type", crate::ViewInfo::default()).unwrap();
for (start, end, hex) in [(0, 1, "#ff0000"), (1, 2, "#0000ff")] {
s.set_tool_option("textSelection", &json!({"id": q2.0, "start": start, "end": end}));
s.execute("paint.setFill", &json!({"color": hex})).unwrap();
}
s.select_tool("selection", crate::ViewInfo::default()).unwrap();
assert_eq!(spec(&s, id).label_styles.categories, None, "part of a label's text is not kept");
s.execute("select.set", &json!({"ids": [q2.0]})).unwrap();
s.execute("recolor.apply", &json!({"map": {"#0000ff": "#ff0000"}})).unwrap();
let looks = label_looks(&mut s, id);
assert!(looks.iter().any(|(t, _, fill)| t == "Q1" && fill == "#ff0000"), "{looks:?}");
assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#000000"), "{looks:?}");
}
/// Stroke options set with `stroke.set` and a gradient made with `paint.editGradient` and placed with
/// `paint.setGradientGeom` on a label's characters are kept for every label of its kind, the gradient without its
/// placement.
#[test]
fn stroke_and_gradient_edits_on_a_labels_characters_are_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let category = label_run_style(&s, id, "Axes", "Q1");
let v = text_id(&s, id, "Axes", "4");
s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap();
s.execute("stroke.set", &json!({"ids": [v.0], "weight": 3, "cap": "round"})).unwrap();
s.execute("paint.editGradient", &json!({"ids": [v.0], "kind": "linear", "stroke": false})).unwrap();
let placement = json!({"ids": [v.0], "stroke": false, "start": [100, 100], "end": [200, 100], "aspect": 50});
s.execute("paint.setGradientGeom", &placement).unwrap();
let fill = label_run_style(&s, id, "Axes", "4").fill;
assert!(matches!(&fill, Paint::Gradient(g) if g.geom.is_some()), "the edited label's gradient is placed: {fill:?}");
label_looks(&mut s, id);
let st = label_run_style(&s, id, "Axes", "0");
assert_eq!((st.stroke_width, st.stroke_cap), (3.0, vectorcraft_doc::LineCap::Round));
assert!(matches!(&st.fill, Paint::Gradient(g) if g.geom.is_none()), "{:?}", st.fill);
assert_eq!(label_run_style(&s, id, "Axes", "Q1"), category, "the category labels are left as they were");
}
/// `stroke.set`, `stroke.weightUp` and `stroke.weightDown` given the whole graph are kept for every kind of label.
#[test]
fn stroke_weights_set_on_the_whole_graph_are_kept_for_its_labels() {
let cases = [("stroke.set", json!({"weight": 2}), 2.0), ("stroke.weightUp", json!({}), 2.0), ("stroke.weightDown", json!({}), 0.75)];
every_case(cases, |(cmd, params, weight)| {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("paint.setStroke", &json!({"ids": [id.0], "color": "#ff0000"})).unwrap();
let mut p = params.clone();
p["ids"] = json!([id.0]);
s.execute(cmd, &p).unwrap();
assert_eq!(label_run_style(&s, id, "Axes", "4").stroke_width, *weight, "{cmd}");
label_looks(&mut s, id);
for (grp, t) in [("Axes", "0"), ("Axes", "Q1"), ("Legend", "2024")] {
assert_eq!(label_run_style(&s, id, grp, t).stroke_width, *weight, "{cmd}: {t}");
}
});
}
/// A stroke weight set on a scaled label is kept as the label shows it, since the graph draws its labels unscaled.
#[test]
fn a_stroke_weight_on_a_scaled_label_is_kept_as_it_shows() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let v = text_id(&s, id, "Axes", "4");
s.execute("select.set", &json!({"ids": [v.0]})).unwrap();
s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap();
s.execute("stroke.set", &json!({"ids": [v.0], "weight": 3})).unwrap();
assert_eq!(label_run_style(&s, id, "Axes", "4").stroke_width, 1.5, "drawn at twice its size");
label_looks(&mut s, id);
assert_eq!(label_run_style(&s, id, "Axes", "0").stroke_width, 3.0);
}
/// When the characters of two labels of one kind are recolored differently, the frontmost one sets what the kind
/// keeps, whatever order `ids` are in.
#[test]
fn the_frontmost_label_of_a_kind_sets_what_its_recolor_keeps() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
// Legend labels: the axes tell value and category labels apart by their order, so these are moved instead.
let (a, b) = (text_id(&s, id, "Legend", "2024"), text_id(&s, id, "Legend", "2025"));
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap();
s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#0000ff"})).unwrap();
// The label made first goes in front, so neither the order of `ids` nor the order the labels were made in decides.
let (front, back) = if a < b { (a, b) } else { (b, a) };
s.execute("select.set", &json!({"ids": [front.0]})).unwrap();
s.execute("object.arrange.bringToFront", &json!({})).unwrap();
let path = |n: NodeId| s.doc().unwrap().doc.index_path(n);
assert!(path(front) > path(back), "{:?} {:?}", path(front), path(back));
let front_fill = if front == a { "#00ffff" } else { "#ffff00" };
s.execute("edit.colors.invert", &json!({"ids": [front.0, back.0]})).unwrap();
let kept = spec(&s, id).label_styles.legend.unwrap().fill;
assert_eq!(kept.color().map(|c| c.to_hex()).as_deref(), Some(front_fill));
}
/// Apply Nearest Swatch and the ink limit of Print Ink Fixes on a label are kept for its kind.
#[test]
fn nearest_swatch_and_ink_limit_on_a_label_are_kept_for_its_kind() {
let cases = [
("addon.color.applyNearestSwatch", json!({"library": "web-safe-216"})),
("addon.color.printFixes", json!({"scope": "selection", "inkLimit": 200})),
];
every_case(cases, |(cmd, params)| {
let mut s = Session::new();
// Print Ink Fixes works on CMYK documents.
s.execute("file.new", &json!({"width": 800, "height": 800, "colorMode": "cmyk"})).unwrap();
let id = graph(&mut s, "column");
let v = text_id(&s, id, "Axes", "4");
s.execute("paint.setFill", &json!({"ids": [v.0], "color": {"c": 90, "m": 80, "y": 70, "k": 60}})).unwrap();
let was = label_run_style(&s, id, "Axes", "4").fill;
let mut p = params.clone();
p["ids"] = json!([v.0]);
s.execute(cmd, &p).unwrap();
let now = label_run_style(&s, id, "Axes", "4").fill;
assert_ne!(now, was, "{cmd} changes the fill");
label_looks(&mut s, id);
assert_eq!(label_run_style(&s, id, "Axes", "0").fill, now, "{cmd}");
});
}
/// Swap Fill and Stroke and Default Fill and Stroke on a label are kept for its kind.
#[test]
fn swap_and_default_fill_and_stroke_on_a_label_are_kept_for_its_kind() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let v = text_id(&s, id, "Axes", "4");
s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap();
s.execute("paint.swap", &json!({"ids": [v.0]})).unwrap();
let swapped = label_run_style(&s, id, "Axes", "4");
label_looks(&mut s, id);
let st = label_run_style(&s, id, "Axes", "0");
assert_eq!((&st.fill, &st.stroke), (&swapped.fill, &swapped.stroke));
assert_eq!(st.fill.color().map(|c| c.to_hex()).as_deref(), Some("#ff0000"));
s.execute("paint.default", &json!({"ids": [text_id(&s, id, "Axes", "4").0]})).unwrap();
label_looks(&mut s, id);
let st = label_run_style(&s, id, "Axes", "0");
assert_eq!((st.fill.color().map(|c| c.to_hex()).as_deref(), st.stroke.is_none()), (Some("#000000"), true));
}
/// On a pie with Legends in Wedges, a recolor of the whole graph leaves the legend labels to the graph's black or white.
#[test]
fn a_recolor_leaves_wedge_legend_labels_their_contrast() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "pie", ",a,b\nP1,1,2");
s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap();
s.execute("edit.colors.invert", &json!({"ids": [id.0]})).unwrap();
assert_eq!(spec(&s, id).label_styles.legend, None);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2]]})).unwrap();
// The first series' black wedge is white now, so its label is black.
let NodeKind::Text(t) = label_node(&s, id, "Legend", "a").kind else { panic!("a is not type") };
assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()).as_deref(), Some("#000000"));
// A recolor of one legend label gives the kind that label's fill.
s.execute("edit.colors.invert", &json!({"ids": [text_id(&s, id, "Legend", "a").0]})).unwrap();
let kept = spec(&s, id).label_styles.legend.map(|st| st.fill);
assert_eq!(kept.and_then(|f| f.color()).map(|c| c.to_hex()).as_deref(), Some("#ffffff"));
}
#[test]
fn pie_names_and_wedge_labels_keep_their_style() {
let mut s = Session::new();
+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);
+7 -5
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Swap Fill and Stroke",
[],
Some("Shift+X"),
"{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight (exceptions: docs/mcp.md)",
"{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight, and on a graph label the swapped paints of its characters for every label of its kind (exceptions: docs/mcp.md)",
has_doc,
swap
),
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Default Fill and Stroke",
[],
Some("D"),
"{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke, and on a graph label the black fill and no stroke of its characters for every label of its kind (exceptions: docs/mcp.md)",
has_doc,
default_paint
),
@@ -96,7 +96,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Invert",
[],
None,
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md); no undo step when no color changes → {changed}",
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke, and on a graph label the new color of its characters and rows for every label of its kind (docs/mcp.md); no undo step when no color changes → {changed}",
has_doc,
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
),
@@ -925,7 +925,8 @@ fn swap(s: &mut Session, p: &Value) -> Result<Value> {
}
// The swap writes the paint of the topmost fill and stroke, not their opacity.
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true, false);
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept))
let labels = super::graph::capture_label_changes(&before, d, &ids, false);
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept || labels))
})?;
}
if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 {
@@ -954,7 +955,8 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, false);
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept))
let labels = super::graph::capture_label_changes(&before, d, &ids, false);
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept || labels))
})?;
}
super::newart::reset(s);
+1 -1
View File
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Recolor Artwork",
["Edit", "Edit Colors"],
None,
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}",
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}",
has_doc,
apply
),
+2 -2
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}",
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters the stroke options they take → {objects: how many objects took the stroke options}",
has_doc,
stroke_set
),
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Weight Up",
[],
Some("Cmd+Alt+."),
"{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn. On a path directly in a graph series, the graph also stores the new weight of the topmost visible stroke (docs/mcp.md) → {objects}",
"{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn. On a path directly in a graph series, the graph also stores the new weight of the topmost visible stroke, and on a graph label the new weight of its characters' stroke or its stroke row for every label of its kind (docs/mcp.md) → {objects}",
has_doc,
|s, p| stroke_step(s, p, 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");
+128
View File
@@ -8894,6 +8894,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.
+117
View File
@@ -7483,3 +7483,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.
File diff suppressed because it is too large Load Diff
+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",
@@ -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`]).
+5
View File
@@ -574,6 +574,10 @@ pub struct UiState {
#[serde(skip)]
pub touch_drawing_tool: Option<String>,
pub status: String,
/// How many times [`crate::VectorcraftApp::status`] has set `status`, so that work finishing
/// later can tell whether the line it started with is still the latest one. Not saved.
#[serde(skip)]
pub status_sets: u64,
/// The Replace Font request behind the status bar's warning ([`ReplaceFontWarning`]). Not saved.
#[serde(skip)]
pub replace_font_warning: Option<ReplaceFontWarning>,
@@ -1163,6 +1167,7 @@ impl Default for UiState {
group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(),
touch_drawing_tool: None,
status: String::new(),
status_sets: 0,
replace_font_warning: None,
status_notes: None,
palette_open: false,
+82
View File
@@ -414,6 +414,88 @@ fn a_copy_that_fails_in_a_host_store_names_the_document() {
}
}
/// Services that write recovery copies on the background worker.
fn on_the_worker() -> Services {
Services { write_shared: Some(Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }
}
/// A copy that finishes after a newer message (a restore's, with its notes) was shown is still
/// written, and the newer message and its notes stay in the status bar.
#[test]
fn a_copy_that_finishes_later_keeps_a_newer_status_and_its_notes() {
let store = Arc::new(MemoryStore::default());
let mut app = app_with(&store, on_the_worker());
recovery::tick(&mut app, 0.0);
assert_eq!(recovery::tick(&mut app, 120.0), 1);
assert_eq!(app.background.jobs.len(), 1, "on the worker");
let newer = "Restored 2 documents with 2 notes: one".to_string();
crate::io::show_with_notes(&mut app, newer.clone(), vec!["one".into(), "two".into()]);
background::wait_all(&mut app);
assert_eq!(copies(&store), 1);
assert!(app.session.doc().unwrap().recovery.is_some());
assert_eq!(app.ui.status, newer);
assert_eq!(app.ui.status_notes, Some((newer, vec!["one".into(), "two".into()])));
}
/// Of two copies started in one round, the first one to finish leaves the second one's
/// "Saving…" line, and the second one reports.
#[test]
fn a_copy_that_finishes_leaves_the_line_of_a_copy_started_after_it() {
let store = Arc::new(MemoryStore::default());
let mut app = app_with(&store, on_the_worker());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
recovery::tick(&mut app, 0.0);
assert_eq!(recovery::tick(&mut app, 120.0), 2);
let second = app.ui.status.clone();
let Some(title) = second.strip_prefix("Saving recovery data for ").and_then(|s| s.strip_suffix('…')) else {
panic!("{second}");
};
let title = title.to_string();
background::wait_until(&mut app, |app| app.background.jobs.len() == 1);
assert_eq!(app.ui.status, second);
background::wait_all(&mut app);
assert_eq!(copies(&store), 2);
assert_eq!(app.ui.status, format!("Saved recovery data for {title}"));
}
/// A copy that fails after a newer message was shown leaves that message in the status bar, in a
/// store the host installed and in a recovery folder; the folder is still recorded.
#[test]
fn a_copy_that_fails_later_keeps_a_newer_status() {
let store: Arc<dyn RecoveryStore> = Arc::new(Refusing(MemoryStore::default()));
let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store), ..on_the_worker() });
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
app.session.prefs.autosave_interval = 2;
recovery::tick(&mut app, 0.0);
assert_eq!(recovery::tick(&mut app, 120.0), 1);
app.status("Newer");
background::wait_all(&mut app);
assert_eq!(app.ui.status, "Newer");
let dir = scratch("later");
let folder = dir.join("recovery");
let shown = folder.to_string_lossy().into_owned();
let mut app = app_in_folder(&shown, on_the_worker());
assert_eq!(recovery::tick(&mut app, 120.0), 1);
background::wait_all(&mut app);
// A folder with a file in it takes the copy's place, so the next copy can't be written.
let name = app.session.doc().unwrap().recovery.as_ref().unwrap().file.clone();
let copy = folder.join(format!("{name}.vectorcraft"));
std::fs::remove_file(&copy).unwrap();
std::fs::create_dir(&copy).unwrap();
std::fs::write(copy.join("in-the-way"), b"").unwrap();
app.run("shape.ellipse", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap();
assert_eq!(recovery::tick(&mut app, 240.0), 1);
app.status("Newer");
background::wait_all(&mut app);
assert_eq!(app.ui.status, "Newer");
assert_eq!(app.recovery.named, std::slice::from_ref(&shown));
drop(app);
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #367: a tab whose timers were paused (in the background) long enough for another tab to
/// take it for gone and discard its copy writes the copy again on its first heartbeat after it
/// resumes, even with the timer off (the copy was written by hand).
+92 -15
View File
@@ -54,6 +54,42 @@ fn click(app: &mut VectorcraftApp, label: &str) {
frame(app, &ctx, vec![]);
}
/// The names and places of the buttons the task bar draws, from the accessibility tree: an
/// icon-only button is named by its hover text.
fn buttons(app: &mut VectorcraftApp) -> (egui::Context, Vec<(String, Rect)>) {
let ctx = egui::Context::default();
ctx.enable_accesskit();
crate::theme::install_fonts(&ctx);
frame(app, &ctx, vec![]);
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| canvas::show(app, ui));
out.textures_delta.clear();
let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes;
let named = nodes
.iter()
.filter(|(_, n)| n.role() == egui::accesskit::Role::Button)
.filter_map(|(_, n)| {
let b = n.bounds()?;
Some((n.label()?.to_string(), Rect::from_min_max(Pos2::new(b.x0 as f32, b.y0 as f32), Pos2::new(b.x1 as f32, b.y1 as f32))))
})
.collect();
(ctx, named)
}
/// Click the task bar's button named `name` (its label, or an icon-only button's hover text).
fn click_button(app: &mut VectorcraftApp, name: &str) {
let (ctx, named) = buttons(app);
let p = named.iter().find(|(n, _)| n == name).unwrap_or_else(|| panic!("no button named {name:?} in {named:?}")).1.center();
let press = |pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, &ctx, vec![Event::PointerMoved(p)]);
frame(app, &ctx, vec![press(true)]);
frame(app, &ctx, vec![press(false)]);
frame(app, &ctx, vec![]);
}
const TO_CORNER: &str = "Convert Selected Anchor Points to Corner";
const TO_SMOOTH: &str = "Convert Selected Anchor Points to Smooth";
fn select(app: &mut VectorcraftApp, ids: &[u64]) {
app.run("select.set", json!({"ids": ids})).unwrap();
}
@@ -293,27 +329,67 @@ fn direct_selection_of_a_whole_path_offers_its_anchor_actions() {
click(&mut app, "Edit Path");
assert_eq!(app.session.tool_id(), "directSelection");
assert!(app.session.active().unwrap().selection.anchors.is_empty(), "the path is selected whole");
let (_, named) = buttons(&mut app);
let names: Vec<&str> = named.iter().map(|(n, _)| n.as_str()).collect();
for name in ["Connect", TO_CORNER, TO_SMOOTH, "Simplify", "Smooth", "Duplicate"] {
assert!(names.contains(&name), "{name}: {names:?}");
}
for name in ["Edit Path", "Offset Path", "Join", "Remove Anchor Points", "Cut Path"] {
assert!(!names.contains(&name), "{name}: {names:?}");
}
// The convert buttons show their icons alone: the bar draws no convert text, and one "Smooth".
let (_, texts) = bar(&mut app);
for label in ["Connect", "Corner", "Smooth", "Simplify", "Duplicate"] {
assert!(has(&texts, label), "{label}: {texts:?}");
}
for label in ["Edit Path", "Offset Path", "Join", "Remove Anchor Points", "Cut Path"] {
assert!(!has(&texts, label), "{label}: {texts:?}");
}
// Smooth and Corner convert every anchor of the path, one undo step each.
click(&mut app, "Smooth");
assert!(!has(&texts, "Corner") && !texts.iter().any(|(t, _)| t.starts_with("Convert")), "{texts:?}");
assert_eq!(texts.iter().filter(|(t, _)| t == "Smooth").count(), 1, "{texts:?}");
// The convert buttons convert every anchor of the path, one undo step each.
click_button(&mut app, TO_SMOOTH);
let a = anchors(&app, line);
assert_eq!(a[1].kind, vectorcraft_geom::AnchorKind::Smooth, "{a:?}");
assert!(a[1].has_in() && a[1].has_out(), "{a:?}");
click(&mut app, "Corner");
click_button(&mut app, TO_CORNER);
assert!(anchors(&app, line).iter().all(|a| !a.has_in() && !a.has_out()), "{:?}", anchors(&app, line));
app.run("edit.undo", json!({})).unwrap();
assert!(anchors(&app, line)[1].has_in(), "Corner undoes in one step");
// Connect closes the open path; a closed one isn't offered it.
click(&mut app, "Connect");
assert!(matches!(kind(&app, line), NodeKind::Path { path, .. } if path.is_closed()));
let (_, texts) = bar(&mut app);
assert!(!has(&texts, "Connect") && has(&texts, "Corner"), "{texts:?}");
let (_, named) = buttons(&mut app);
assert!(!named.iter().any(|(n, _)| n == "Connect") && named.iter().any(|(n, _)| n == TO_CORNER), "{named:?}");
}
/// With the Direct Selection tool, the bar's Smooth smooths the path's curvature: it opens the
/// Smooth slider, as Object › Path › Smooth… does, and changes nothing until the slider is
/// confirmed (#6784). Simplify opens its slider in the same way.
#[test]
fn direct_selection_smooth_opens_the_smooth_slider() {
for (name, kind) in [("Smooth", crate::dialogs::smooth_slider::KIND), ("Simplify", crate::dialogs::simplify_slider::KIND)] {
let mut app = app();
// A jagged line of 40 anchor points, which Smooth changes at its opening level.
let anchors_in: Vec<_> = (0..40).map(|i| json!({"x": 50 + i * 5, "y": 150 + (i % 2) * 6})).collect();
let line = id(app.run("path.create", json!({"anchors": anchors_in})).unwrap());
app.run("tool.select", json!({"tool": "directSelection"})).unwrap();
select(&mut app, &[line]);
let before = anchors(&app, line);
let undo = app.session.active().unwrap().history.undo.len();
click_button(&mut app, name);
let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("{name} opens its slider: {}", app.ui.status));
assert_eq!(d.kind, kind, "{name}");
assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "{name} adds no undo step before it is confirmed");
crate::dialogs::cancel(&mut app);
assert_eq!(anchors(&app, line), before, "{name}: canceling leaves the path as it was");
if name != "Smooth" {
continue;
}
// Confirmed, the slider smooths the path as one undo step, which is not the anchor convert.
click_button(&mut app, name);
crate::dialogs::confirm(&mut app).unwrap();
let history = &app.session.active().unwrap().history.undo;
assert_eq!(history.len(), undo + 1, "one undo step");
assert_ne!(history.last().unwrap().label, "Convert Anchor Points");
assert_ne!(anchors(&app, line), before, "Smooth changes the path's curvature");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(anchors(&app, line), before, "undo restores the path");
}
}
/// Anchors picked out with the Direct Selection tool keep the full set: Remove and Cut need them.
@@ -323,11 +399,12 @@ fn direct_selected_anchors_still_offer_remove_and_cut() {
let line = id(app.run("path.create", json!({"anchors": [{"x": 50, "y": 200}, {"x": 150, "y": 80}, {"x": 250, "y": 200}]})).unwrap());
app.run("tool.select", json!({"tool": "directSelection"})).unwrap();
app.run("select.anchors", json!({"id": line, "anchors": [[0, 1]]})).unwrap();
let (_, texts) = bar(&mut app);
for label in ["Remove Anchor Points", "Connect", "Cut Path", "Corner", "Smooth"] {
assert!(has(&texts, label), "{label}: {texts:?}");
let (_, named) = buttons(&mut app);
let names: Vec<&str> = named.iter().map(|(n, _)| n.as_str()).collect();
for name in ["Remove Anchor Points", "Connect", "Cut Path", TO_CORNER, TO_SMOOTH] {
assert!(names.contains(&name), "{name}: {names:?}");
}
assert!(!has(&texts, "Edit Path"), "{texts:?}");
assert!(!names.contains(&"Edit Path"), "{names:?}");
}
/// Click the task bar's button left of More Options (Lock, or a basic shape's Shape Properties),
File diff suppressed because one or more lines are too long
+14 -8
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -38,7 +38,7 @@ Chinese by `imachus`). Georgian and the original Gujarati catalog are recorded a
| Portuguese | `pt-br` | 9,260 rows, 3,445 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 39 missing messages for it; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) |
| Indonesian | `id` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one plural form | dialogs and tooltips all; 750 message rows (French has 796) | Latin | no | full | — (review) |
| Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) |
| German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (3,433 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 48 missing messages for it) | Latin | contributor-written, not reviewed | full | — (review) |
| German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (3,563 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 52 missing messages for it, which open #7116, #7211 and #7378 translate) | Latin | contributor-written, not reviewed | full | — (review) |
| Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | all (3,438 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 39 missing messages for it) | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) |
| Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | all | Latin with Vietnamese diacritics (stacked marks attach in precomposed, decomposed and keyboard forms, #4491; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) |
@@ -54,7 +54,7 @@ plus review. Native-speaker review is human work and isn't in the hours.
|---|---|---|---|
| Traditional Chinese (Taiwan) | `zh-hant` | 8,820 rows, 3,438 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it; menus, `tl!` literals and messages were complete when counted on 2026-10-21 (80% overall) | partial |
| Italian | `it` | 8,757 rows, 3,422 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it | full |
| Dutch | `nl` | 7,299 rows, 1,609 of them `@msg` and 47 `@msgplural` (counted 2026-11-13 on `main` at `9a1a158a3`): in `COMPLETE_MESSAGES`, and the untranslated report lists the same missing messages for it as for German; one/other plurals | full |
| Dutch | `nl` | 3,447 `@msg` and 48 `@msgplural` rows (counted 2026-11-13): in `COMPLETE_MESSAGES`, and the untranslated report lists 41 missing messages for it; one/other plurals | full |
| Danish | `da` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one/other plurals | full |
| Russian | `ru` | 4,009 / 4,020 | full |
| Ukrainian | `uk` | 8,802 rows, 3,439 of them `@msg` and 48 `@msgplural` (counted 2026-11-13); one/few/many plurals | full |
+28 -9
View File
@@ -841,8 +841,18 @@ of the series, the legend swatch included, takes it at the same place on its own
after the data changes or the graph moves. The graph saves this as `fillUnitBox` and `strokeUnitBox` in its series
paints. A gradient stored from a path with no width or no height is drawn at the place in the document where it was
stored. So is a gradient a graph stored without these flags. The graph does not store a stroke's dashes, caps, joins,
arrowheads, width profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again
with the paint they had.
arrowheads, width profile or brush. On a graph label, given the label or something that holds it,
these commands, `paint.swap` and `paint.default` store what they change for every label of its kind: the label's object
rows, as the `appearance.*` commands store them, and the fill, stroke, stroke weight, caps, joins, miter limit, dashes
and stroke alignment of its characters when all of its characters show them alike. A gradient on the characters is
stored without its placement (its vector, aspect ratio, focal point and freeform points), and each label fits it to its
own box when the graph is drawn again, so `paint.setGradientGeom` and the `paint.freeform.*` edits do not store the
placement they set there. A stroke weight and dashes are stored as the label shows them, also on a label that was
scaled. When the characters of labels of one kind change differently, the frontmost label sets what the kind keeps. On
a pie with Legends in Wedges whose legend labels have no fill of their own, the legend labels' fill is stored only when
every legend label the command reaches ends up with the same fill. Otherwise they are drawn again in black, or white on
a dark wedge. These commands do not store the colors they give a graph's axis lines, ticks and radar rings, which are
drawn again with their default paint.
`text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes
it sets for each kind of label it reaches (value axis numbers, category labels, legend labels), and every label of that
@@ -3551,6 +3561,12 @@ already walks Image Trace. Short agent entry points:
`livePaint.info {group}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?,
color?, closeGaps?}` (Object › Live Paint › Gap Options). Make gives each covered face the fill of its front-most
covering source, including compound paths with shared curved boundaries. Expand keeps those face fills.
`livePaint.expand` keeps each face and edge that draws, with all its fill and stroke rows: it is shown, its opacity
is above 0, and a fill or stroke row is shown, has paint and an opacity above 0 (a stroke also a weight above 0),
whichever row is on top. A painted lower row under an unpainted top row keeps the part; a part whose only painted
row is hidden is removed. `livePaint.merge` copies the fill and stroke rows, visibility, opacity and blending mode of
each face that draws by this rule, and of each edge, to the rebuilt parts, with their gradients kept at their fit on
the old outline and without the strokes' pen pressure, tilt, bearing and rotation.
With `detect: true`, paint stops at the gaps that run
from a loose end of a path to the nearest path, up to `size` wide (`small` 3 pt, `medium` 6 pt, `large` 12 pt, or a
number of points up to 72); `closeGaps: true` adds an unpainted path across each of them. `color` (the Gap Preview
@@ -4340,8 +4356,8 @@ with the same extension and bytes as one there, loads as that User Defined libra
## Symbol libraries
Symbol libraries are read-only sets of symbols: five generated in code (Basic Shapes, UI Frames, Map Markers,
Inventory Icons, Skill Icons), the `.vectorcraft` files in
Symbol libraries are read-only sets of symbols: six generated in code (Basic Shapes, UI Frames, Map Markers,
Inventory Icons, Skill Icons, Weather Icons), the `.vectorcraft` files in
the user library folder of the desktop app (User Defined) and the symbols of any document loaded with
`symbol.loadLibrary`. `symbol.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`)
and `symbol.library {library}` returns one's symbols (`name`, `size`, `registration`, `exportType`; `library` is an
@@ -5365,9 +5381,10 @@ keeps the segment's direction when the smoothed path there turns back by 90° or
path, the curve keeps the direction of the part outside the drag. `object.path.smooth` and Simplify keep each end's
direction.
In the desktop app, Object › Path › Smooth and the canvas context menu's Smooth… (listed while the selection holds a
path) open the slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog `smoothSlider` with field `level`) at
the Auto-Smooth level, and the slider previews the result until `ui.dialog.confirm`; `ui.smoothAuto` moves the open
In the desktop app, Object › Path › Smooth, the canvas context menu's Smooth… (listed while the selection holds a
path) and the Contextual Task Bar's Smooth (with a path selected whole with the Direct Selection tool) open the
slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog `smoothSlider` with field `level`) at the
Auto-Smooth level, and the slider previews the result until `ui.dialog.confirm`; `ui.smoothAuto` moves the open
slider back to the Auto-Smooth level.
## Editing a path with the Curvature tool
@@ -5616,7 +5633,9 @@ one undo step.
`object.setLiveShape` returns a parameter error, and leaves the document and its undo history as they were, for a key
it does not take, at the top level (`` unknown parameter `path` ``) or in one of `items`
(`` unknown parameter `items[0].corner`; did you mean `items[0].corners`? ``), for a `sides` that is not a whole number
from 3 to 1000, such as 2.5, 2 or 1001, and for a negative `radius`. A `radius` of 0 removes the rounding.
from 3 to 1000, such as 2.5, 2 or 1001, and for a negative `radius`. A `radius` of 0 removes the rounding. Saved
steps (a journal or an action) replay these values without an error: a negative `radius` counts as 0, a whole `sides`
below 3 or above 1000 counts as 3 or 1000, and a fractional or negative `sides` is ignored.
```json
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"sides":8,"polygonAngle":22.5,"sideLength":40}}}
@@ -6582,7 +6601,7 @@ file's composite is drawn through clips the image (a clipping group, its path un
clipping path kept as the same area), and `file.place` answers the image's id as
`image`, since the Links commands take the image and not the group. `links.update` and `links.relink` clip the image
by the file's clipping path again (a plain group holding just the image becomes its clipping group, a new clipping group holds it when no group does, and it goes inside a clipping mask made in the
document, whose path is kept), unless Placement Options clip the image, the same way for the images in symbols, graph designs, patterns and opacity masks; when the new file has no
document, whose path is kept), the same way for the images in symbols, graph designs, patterns and opacity masks. When Placement Options also clip the image, the file's clipping group goes inside a clipping group of the placement's clip (the clipping group holding just the file's one, or a clipping group holding just the image that is not the file's, takes that clip, else a new one does), and a later update or relink changes the same two groups; when the new file has no
clipping path, a clipping group holding just the image whose path is the old file's clipping path loses that path and
stays as a plain group of the image, with its id, name, visibility and transparency (one whose path was given the
even-odd fill rule stays as it is). The link shows as modified when a plate file of its own changes
+2 -2
View File
@@ -617,7 +617,7 @@ An **importer** gets the bytes of the file being opened, as they are (not JSON),
`paint`, `width` in points, `join` (`"miter"`, `"round"` or `"bevel"`), `cap` (`"butt"`, `"round"` or `"square"`)
and `miterLimit`. Paints use the native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`,
`{"type": "gradient", "gradient": {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity,
midpoint}], "dither"?, "method"?: "Perceptual"|"HueClockwise"|"HueCounterclockwise"}, "geom"?, "angle", "freeform"?}`, `{"type": "pattern", "pattern",
midpoint, swatch?, tint?, plates?}], "dither"?, "method"?: "Perceptual"|"HueClockwise"|"HueCounterclockwise"}, "geom"?, "angle", "freeform"?}`, `{"type": "pattern", "pattern",
"xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk", c, m, y, k}`, `{"model": "gray", k}` (ink: 0
white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except Lab's.
- `effects` (filters): the object's own effects in order, as its effect records: `id` (as `effect.list` lists it, or
@@ -742,7 +742,7 @@ most 100,000 objects and 4,000,000 anchors, 256 fills, strokes or gradient stops
clamped into range, stroke weights to 0–1,000 pt and miter limits to 1–500; a pattern must exist in the document. A
gradient stop's four process `plates` are clamped to 0–1 as well: a value that is not a number becomes 0, and an
infinite one becomes 0 or 1 by its sign. A paint returned unchanged keeps its swatch link; any other paint is
unlinked. One refused effect record refuses the whole output.
unlinked, and each of its gradient stops then has no swatch, a tint of 1 and no `plates`. One refused effect record refuses the whole output.
A filter's output may also hold `documentData`: any JSON of at most 1 MiB, which replaces what the plug-in keeps with
the document (`null` removes it), in the same undo step as its objects. Settings that belong to the whole document
+1 -1
View File
@@ -165,7 +165,7 @@ Gujarati interface follow-up (2026-11-14): the catalog and zero/one plural rule
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, nine generated libraries including Ink and Wash, Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, six generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons, Weather Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, nine generated libraries including Ink and Wash, Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369); Live Paint Bucket fills faces crossed in one drag (#6373) | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 |
File diff suppressed because one or more lines are too long